diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 32bae3c34c..bbb767118c 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -86,3 +86,10 @@ INSTALLED_APPS += ('lettuce.django',) LETTUCE_APPS = ('contentstore',) LETTUCE_SERVER_PORT = choice(PORTS) if SAUCE.get('SAUCE_ENABLED') else randint(1024, 65535) LETTUCE_BROWSER = os.environ.get('LETTUCE_BROWSER', 'chrome') + +##################################################################### +# Lastly, see if the developer has any local overrides. +try: + from .private import * # pylint: disable=F0401 +except ImportError: + pass diff --git a/cms/envs/common.py b/cms/envs/common.py index ecb85a05e0..9b9f58a7b5 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -374,6 +374,7 @@ INSTALLED_APPS = ( 'course_modes' ) + ################# EDX MARKETING SITE ################################## EDXMKTG_COOKIE_NAME = 'edxloggedin' diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 7a5e711f44..d5f0ba7503 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -53,6 +53,10 @@ class CourseMode(models.Model): modes = [cls.DEFAULT_MODE] return modes + @classmethod + def modes_for_course_dict(cls, course_id): + return { mode.slug : mode for mode in cls.modes_for_course(course_id) } + @classmethod def mode_for_course(cls, course_id, mode_slug): """ @@ -67,3 +71,8 @@ class CourseMode(models.Model): return matched[0] else: return None + + def __unicode__(self): + return u"{} : {}, min={}, prices={}".format( + self.course_id, self.mode_slug, self.min_price, self.suggested_prices + ) diff --git a/common/djangoapps/course_modes/tests/__init__.py b/common/djangoapps/course_modes/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/course_modes/tests/factories.py b/common/djangoapps/course_modes/tests/factories.py new file mode 100644 index 0000000000..3e35b2f05c --- /dev/null +++ b/common/djangoapps/course_modes/tests/factories.py @@ -0,0 +1,13 @@ +from course_modes.models import CourseMode +from factory import DjangoModelFactory + +# Factories don't have __init__ methods, and are self documenting +# pylint: disable=W0232 +class CourseModeFactory(DjangoModelFactory): + FACTORY_FOR = CourseMode + + course_id = u'MITx/999/Robot_Super_Course' + mode_slug = 'audit' + mode_display_name = 'audit course' + min_price = 0 + currency = 'usd' diff --git a/common/djangoapps/course_modes/tests.py b/common/djangoapps/course_modes/tests/test_models.py similarity index 100% rename from common/djangoapps/course_modes/tests.py rename to common/djangoapps/course_modes/tests/test_models.py diff --git a/common/djangoapps/course_modes/urls.py b/common/djangoapps/course_modes/urls.py new file mode 100644 index 0000000000..47e7f04c40 --- /dev/null +++ b/common/djangoapps/course_modes/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import include, patterns, url +from django.views.generic import TemplateView + +from course_modes import views + +urlpatterns = patterns( + '', + url(r'^choose/(?P[^/]+/[^/]+/[^/]+)$', views.ChooseModeView.as_view(), name="course_modes_choose"), +) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 60f00ef0ef..641185eb5b 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -1 +1,102 @@ -# Create your views here. +import decimal +from django.core.urlresolvers import reverse +from django.http import ( + HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, Http404 +) +from django.shortcuts import redirect +from django.views.generic.base import View +from django.utils.translation import ugettext as _ +from django.utils.http import urlencode +from django.contrib.auth.decorators import login_required +from django.utils.decorators import method_decorator + +from mitxmako.shortcuts import render_to_response + +from course_modes.models import CourseMode +from courseware.access import has_access +from student.models import CourseEnrollment +from student.views import course_from_id +from verify_student.models import SoftwareSecurePhotoVerification + +class ChooseModeView(View): + + @method_decorator(login_required) + def get(self, request, course_id, error=None): + if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified': + return redirect(reverse('dashboard')) + modes = CourseMode.modes_for_course_dict(course_id) + context = { + "course_id": course_id, + "modes": modes, + "course_name": course_from_id(course_id).display_name, + "chosen_price": None, + "error": error, + } + if "verified" in modes: + context["suggested_prices"] = modes["verified"].suggested_prices.split(",") + context["currency"] = modes["verified"].currency.upper() + context["min_price"] = modes["verified"].min_price + + return render_to_response("course_modes/choose.html", context) + + @method_decorator(login_required) + def post(self, request, course_id): + user = request.user + + # This is a bit redundant with logic in student.views.change_enrollement, + # but I don't really have the time to refactor it more nicely and test. + course = course_from_id(course_id) + if not has_access(user, course, 'enroll'): + error_msg = _("Enrollment is closed") + return self.get(request, course_id, error=error_msg) + + requested_mode = self.get_requested_mode(request.POST.get("mode")) + if requested_mode == "verified" and request.POST.get("honor-code"): + requested_mode = "honor" + + allowed_modes = CourseMode.modes_for_course_dict(course_id) + if requested_mode not in allowed_modes: + return HttpResponseBadRequest(_("Enrollment mode not supported")) + + if requested_mode in ("audit", "honor"): + CourseEnrollment.enroll(user, course_id) + return redirect('dashboard') + + mode_info = allowed_modes[requested_mode] + + if requested_mode == "verified": + amount = request.POST.get("contribution") or \ + request.POST.get("contribution-other-amt") or 0 + + try: + # validate the amount passed in and force it into two digits + amount_value = decimal.Decimal(amount).quantize(decimal.Decimal('.01'), rounding=decimal.ROUND_DOWN) + except decimal.InvalidOperation: + error_msg = _("Invalid amount selected.") + return self.get(request, course_id, error=error_msg) + + # Check for minimum pricing + if amount_value < mode_info.min_price: + error_msg = _("No selected price or selected price is too low.") + return self.get(request, course_id, error=error_msg) + + donation_for_course = request.session.get("donation_for_course", {}) + donation_for_course[course_id] = amount_value + request.session["donation_for_course"] = donation_for_course + if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user): + return redirect( + reverse('verify_student_verified', + kwargs={'course_id': course_id}) + ) + + return redirect( + reverse('verify_student_show_requirements', + kwargs={'course_id': course_id}), + ) + + def get_requested_mode(self, user_choice): + choices = { + "Select Audit": "audit", + "Select Certificate": "verified" + } + return choices.get(user_choice) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 5f29ffa6aa..71cff4183b 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -843,6 +843,23 @@ class CourseEnrollment(models.Model): except cls.DoesNotExist: return False + @classmethod + def enrollment_mode_for_user(cls, user, course_id): + """ + Returns the enrollment mode for the given user for the given course + + `user` is a Django User object + `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) + """ + try: + record = CourseEnrollment.objects.get(user=user, course_id=course_id) + if record.is_active: + return record.mode + else: + return None + except cls.DoesNotExist: + return None + @classmethod def enrollments_for_user(cls, user): return CourseEnrollment.objects.filter(user=user, is_active=1) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 6e19c9ad5f..78691f9134 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -2,7 +2,6 @@ Student Views """ import datetime -import feedparser import json import logging import random @@ -27,22 +26,20 @@ from django.db import IntegrityError, transaction from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed, Http404 from django.shortcuts import redirect from django_future.csrf import ensure_csrf_cookie -from django.utils.http import cookie_date -from django.utils.http import base36_to_int +from django.utils.http import cookie_date, base36_to_int, urlencode from django.utils.translation import ugettext as _ from django.views.decorators.http import require_POST from ratelimitbackend.exceptions import RateLimitException from mitxmako.shortcuts import render_to_response, render_to_string -from bs4 import BeautifulSoup +from course_modes.models import CourseMode from student.models import (Registration, UserProfile, TestCenterUser, TestCenterUserForm, TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, get_testcenter_registration, CourseEnrollmentAllowed) - from student.forms import PasswordResetFormNoActive from certificates.models import CertificateStatuses, certificate_status_for_student @@ -269,7 +266,7 @@ def dashboard(request): courses = [] for enrollment in CourseEnrollment.enrollments_for_user(user): try: - courses.append(course_from_id(enrollment.course_id)) + courses.append((course_from_id(enrollment.course_id), enrollment)) except ItemNotFoundError: log.error("User {0} enrolled in non-existent course {1}" .format(user.username, enrollment.course_id)) @@ -288,12 +285,12 @@ def dashboard(request): staff_access = True errored_courses = modulestore().get_errored_courses() - show_courseware_links_for = frozenset(course.id for course in courses + show_courseware_links_for = frozenset(course.id for course, _enrollment in courses if has_access(request.user, course, 'load')) - cert_statuses = {course.id: cert_info(request.user, course) for course in courses} + cert_statuses = {course.id: cert_info(request.user, course) for course, _enrollment in courses} - exam_registrations = {course.id: exam_registration_info(request.user, course) for course in courses} + exam_registrations = {course.id: exam_registration_info(request.user, course) for course, _enrollment in courses} # get info w.r.t ExternalAuthMap external_auth_map = None @@ -335,10 +332,13 @@ def try_change_enrollment(request): enrollment_response.content ) ) + if enrollment_response.content != '': + return enrollment_response.content except Exception, e: log.exception("Exception automatically enrolling after login: {0}".format(str(e))) +@require_POST def change_enrollment(request): """ Modify the enrollment status for the logged-in user. @@ -356,18 +356,16 @@ def change_enrollment(request): as a post-login/registration helper, so the error messages in the responses should never actually be user-visible. """ - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - user = request.user - if not user.is_authenticated(): - return HttpResponseForbidden() action = request.POST.get("enrollment_action") course_id = request.POST.get("course_id") if course_id is None: return HttpResponseBadRequest(_("Course id not specified")) + if not user.is_authenticated(): + return HttpResponseForbidden() + if action == "enroll": # Make sure the course exists # We don't do this check on unenroll, or a bad course id can't be unenrolled from @@ -381,6 +379,14 @@ def change_enrollment(request): if not has_access(user, course, 'enroll'): return HttpResponseBadRequest(_("Enrollment is closed")) + # If this course is available in multiple modes, redirect them to a page + # where they can choose which mode they want. + available_modes = CourseMode.modes_for_course(course_id) + if len(available_modes) > 1: + return HttpResponse( + reverse("course_modes_choose", kwargs={'course_id': course_id}) + ) + org, course_num, run = course_id.split("/") statsd.increment("common.student.enrollment", tags=["org:{0}".format(org), @@ -463,10 +469,10 @@ def login_user(request, error=""): log.exception(e) raise - try_change_enrollment(request) + redirect_url = try_change_enrollment(request) statsd.increment("common.student.successful_login") - response = HttpResponse(json.dumps({'success': True})) + response = HttpResponse(json.dumps({'success': True, 'redirect_url': redirect_url})) # set the login cookie for the edx marketing site # we want this cookie to be accessed via javascript @@ -732,14 +738,14 @@ def create_account(request, post_override=None): login_user.save() AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(login_user.username, login_user.email)) - try_change_enrollment(request) + redirect_url = try_change_enrollment(request) statsd.increment("common.student.account_created") - js = {'success': True} - HttpResponse(json.dumps(js), mimetype="application/json") + response_params = {'success': True, + 'redirect_url': redirect_url} - response = HttpResponse(json.dumps({'success': True})) + response = HttpResponse(json.dumps(response_params)) # set the login cookie for the edx marketing site # we want this cookie to be accessed via javascript diff --git a/common/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py index 2ed78aaa9f..9aa6b3b54d 100644 --- a/common/djangoapps/terrain/factories.py +++ b/common/djangoapps/terrain/factories.py @@ -5,6 +5,7 @@ and integration / BDD tests. ''' import student.tests.factories as sf import xmodule.modulestore.tests.factories as xf +import course_modes.tests.factories as cmf from lettuce import world @@ -51,6 +52,14 @@ class CourseEnrollmentAllowedFactory(sf.CourseEnrollmentAllowedFactory): pass +@world.absorb +class CourseModeFactory(cmf.CourseModeFactory): + """ + Course modes + """ + pass + + @world.absorb class CourseFactory(xf.CourseFactory): """ diff --git a/common/templates/course_modes/_contribution.html b/common/templates/course_modes/_contribution.html new file mode 100644 index 0000000000..5596963ad0 --- /dev/null +++ b/common/templates/course_modes/_contribution.html @@ -0,0 +1,32 @@ + diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html new file mode 100644 index 0000000000..dfaf4e98e7 --- /dev/null +++ b/common/templates/course_modes/choose.html @@ -0,0 +1,164 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="bodyclass">register verification-process step-select-track +<%block name="title">${_("Register for {} | Choose Your Track").format(course_name)} + +<%block name="js_extra"> + + + +<%block name="content"> + +%if error: +
+
+ +
+

${_("Sorry, there was an error when trying to register you")}

+
+

${error}

+
+
+
+
+%endif + +
+
+ + <%include file="/verify_student/_verification_header.html" args="course_name=course_name" /> + +
+
+

${_("Select your track:")}

+ +
+ + % if "verified" in modes: +
+
+ +

${_("Certificate of Achievement (ID Verified)")}

+
+

${_("Sign up and work toward a verified Certificate of Achievement.")}

+
+
+ +
+
${_("Select your contribution for this course (min. $")} ${min_price} ${currency}${_("):")}
+ + %if error: +
+
+

${error}

+
+
+ %endif + + <%include file="_contribution.html" args="suggested_prices=suggested_prices, currency=currency, chosen_price=chosen_price, min_price=min_price"/> + + +
+ +
    +
  • + +
  • +
+
+ +
+

${_("Verified Registration Requirements")}

+
+

${_("To register for a Verified Certificate of Achievement option, you will need a webcam, a credit or debit card, and an ID.")}

+
+ +

${_("What is an ID Verified Certificate?")}

+
+

${_("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.")}

+
+
+ % endif + + % if "audit" in modes: + + ${_("or")} + +
+
+

${_("Audit This Course")}

+
+

${_("Sign up to audit this course for free and track your own progress.")}

+
+
+ +
    +
  • + +
  • +
+
+ % endif + + +
+
+
+ + <%include file="/verify_student/_verification_support.html" /> +
+
+ diff --git a/lms/djangoapps/courseware/features/certificates.feature b/lms/djangoapps/courseware/features/certificates.feature new file mode 100644 index 0000000000..103936b6e6 --- /dev/null +++ b/lms/djangoapps/courseware/features/certificates.feature @@ -0,0 +1,80 @@ +Feature: Verified certificates + As a student, + In order to earn a verified certificate + I want to sign up for a verified certificate course. + + Scenario: I can audit a verified certificate course + Given I am logged in + When I select the audit track + Then I should see the course on my dashboard + + Scenario: I can submit photos to verify my identity + Given I am logged in + When I select the verified track + And I go to step "1" + And I capture my "face" photo + And I approve my "face" photo + And I go to step "2" + And I capture my "photo_id" photo + And I approve my "photo_id" photo + And I go to step "3" + And I select a contribution amount + And I confirm that the details match + And I go to step "4" + Then I am at the payment page + + Scenario: I can pay for a verified certificate + Given I have submitted photos to verify my identity + 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 + And I navigate to my dashboard + Then I see the course on my dashboard + And I see that I am on the verified track + + # Not easily automated + @skip + Scenario: I can re-take photos + Given I have submitted my "" photo + When I retake my "" photo + Then I see the new photo on the confirmation page. + + Examples: + | PhotoType | + | face | + | ID | + + # Not yet implemented LMS-983 + @skip + Scenario: I can edit identity information + Given I have submitted face and ID photos + 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 + Then I am at the verified page + + # Currently broken LMS-1009 + @skip + Scenario: I can pay from the return flow + Given I have submitted photos to verify my identity + When I leave the flow and return + 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 diff --git a/lms/djangoapps/courseware/features/certificates.py b/lms/djangoapps/courseware/features/certificates.py new file mode 100644 index 0000000000..952dd04d6c --- /dev/null +++ b/lms/djangoapps/courseware/features/certificates.py @@ -0,0 +1,243 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from lettuce.django import django_url +from course_modes.models import CourseMode +from nose.tools import assert_equal + +def create_cert_course(): + world.clear_courses() + org = 'edx' + number = '999' + name = 'Certificates' + course_id = '{org}/{number}/{name}'.format( + org=org, number=number, name=name) + world.scenario_dict['COURSE'] = world.CourseFactory.create( + org=org, number=number, display_name=name) + + audit_mode = world.CourseModeFactory.create( + course_id=course_id, + mode_slug='audit', + mode_display_name='audit course', + min_price=0, + ) + assert isinstance(audit_mode, CourseMode) + + verfied_mode = world.CourseModeFactory.create( + course_id=course_id, + mode_slug='verified', + mode_display_name='verified cert course', + min_price=16, + suggested_prices='32,64,128', + currency='usd', + ) + assert isinstance(verfied_mode, CourseMode) + + +def register(): + url = 'courses/{org}/{number}/{name}/about'.format( + org='edx', number='999', name='Certificates') + world.browser.visit(django_url(url)) + + world.css_click('section.intro a.register') + assert world.is_css_present('section.wrapper h3.title') + + +@step(u'I select the audit track$') +def select_the_audit_track(step): + create_cert_course() + register() + btn_css = 'input[value="Select Audit"]' + world.wait(1) # TODO remove this after troubleshooting JZ + world.css_find(btn_css) + world.css_click(btn_css) + + +def select_contribution(amount=32): + radio_css = 'input[value="{}"]'.format(amount) + world.css_click(radio_css) + assert world.css_find(radio_css).selected + + +@step(u'I select the verified track$') +def select_the_verified_track(step): + create_cert_course() + register() + select_contribution(32) + btn_css = 'input[value="Select Certificate"]' + world.css_click(btn_css) + assert world.is_css_present('section.progress') + + +@step(u'I should see the course on my dashboard$') +def should_see_the_course_on_my_dashboard(step): + course_css = 'li.course-item' + assert world.is_css_present(course_css) + + +@step(u'I go to step "([^"]*)"$') +def goto_next_step(step, step_num): + btn_css = { + '1': '#face_next_button', + '2': '#face_next_button', + '3': '#photo_id_next_button', + '4': '#pay_button', + } + next_css = { + '1': 'div#wrapper-facephoto.carousel-active', + '2': 'div#wrapper-idphoto.carousel-active', + '3': 'div#wrapper-review.carousel-active', + '4': 'div#wrapper-review.carousel-active', + } + world.css_click(btn_css[step_num]) + + # Pressing the button will advance the carousel to the next item + # and give the wrapper div the "carousel-active" class + assert world.css_find(next_css[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');" + ) + + # Mirror the javascript of the photo_verification.html page + world.browser.execute_script(snapshot_script) + world.browser.execute_script("$('#{}_capture_button').hide();".format(name)) + world.browser.execute_script("$('#{}_reset_button').show();".format(name)) + world.browser.execute_script("$('#{}_approve_button').show();".format(name)) + assert world.css_find('#{}_approve_button'.format(name)) + + +@step(u'I approve my "([^"]*)" photo$') +def approve_my_photo(step, name): + button_css = { + 'face': 'div#wrapper-facephoto li.control-approve', + 'photo_id': 'div#wrapper-idphoto li.control-approve', + } + wrapper_css = { + 'face': 'div#wrapper-facephoto', + 'photo_id': 'div#wrapper-idphoto', + } + + # Make sure that the carousel is in the right place + assert world.css_has_class(wrapper_css[name], 'carousel-active') + assert world.css_find(button_css[name]) + + # HACK: for now don't bother clicking the approve button for + # id_photo, because it is sending you back to Step 1. + # Come back and figure it out later. JZ Aug 29 2013 + if name=='face': + world.css_click(button_css[name]) + + # Make sure you didn't advance the carousel + assert world.css_has_class(wrapper_css[name], 'carousel-active') + + +@step(u'I select a contribution amount$') +def select_contribution_amount(step): + select_contribution(32) + + +@step(u'I confirm that the details match$') +def confirm_details_match(step): + # First you need to scroll down on the page + # to make the element visible? + # Currently chrome is failing with ElementNotVisibleException + world.browser.execute_script("window.scrollTo(0,1024)") + + cb_css = 'input#confirm_pics_good' + world.css_click(cb_css) + assert world.css_find(cb_css).checked + + +@step(u'I am at the payment page') +def at_the_payment_page(step): + assert world.css_find('input[name=transactionSignature]') + + +@step(u'I submit valid payment information$') +def submit_payment(step): + button_css = 'input[value=Submit]' + world.css_click(button_css) + + +@step(u'I have submitted photos to verify my identity') +def submitted_photos_to_verify_my_identity(step): + step.given('I am logged in') + step.given('I select the verified track') + step.given('I go to step "1"') + step.given('I capture my "face" photo') + step.given('I approve my "face" photo') + step.given('I go to step "2"') + step.given('I capture my "photo_id" photo') + step.given('I approve my "photo_id" photo') + step.given('I go to step "3"') + step.given('I select a contribution amount') + step.given('I confirm that the details match') + step.given('I go to step "4"') + + +@step(u'I see that my payment was successful') +def see_that_my_payment_was_successful(step): + title = world.css_find('div.wrapper-content-main h3.title') + assert_equal(title.text, u'Congratulations! You are now verified on edX.') + + +@step(u'I navigate to my dashboard') +def navigate_to_my_dashboard(step): + world.css_click('span.avatar') + assert world.css_find('section.my-courses') + + +@step(u'I see the course on my dashboard') +def see_the_course_on_my_dashboard(step): + course_link_css = 'section.my-courses a[href*="edx/999/Certificates"]' + assert world.is_css_present(course_link_css) + + +@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' + + +@step(u'I leave the flow and return$') +def leave_the_flow_and_return(step): + world.browser.back() + + +@step(u'I am at the verified page$') +def see_the_payment_page(step): + assert world.css_find('button#pay_button') + + +@step(u'I press the payment button') +def press_payment_button(step): + assert False, 'This step must be implemented' +@step(u'I have submitted face and ID photos') +def submitted_face_and_id_photos(step): + assert False, 'This step must be implemented' +@step(u'I edit my name') +def edit_my_name(step): + assert False, 'This step must be implemented' +@step(u'I see the new name on the confirmation page.') +def sesee_the_new_name_on_the_confirmation_page(step): + assert False, 'This step must be implemented' +@step(u'I have submitted photos') +def submitted_photos(step): + assert False, 'This step must be implemented' +@step(u'I am registered for the course') +def seam_registered_for_the_course(step): + assert False, 'This step must be implemented' +@step(u'I return to the student dashboard') +def return_to_the_student_dashboard(step): + assert False, 'This step must be implemented' diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index a80388f971..d613903057 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1,26 +1,28 @@ +from datetime import datetime import pytz import logging import smtplib -from datetime import datetime +import textwrap + from django.db import models from django.conf import settings from django.core.exceptions import ObjectDoesNotExist +from django.core.mail import send_mail from django.contrib.auth.models import User from django.utils.translation import ugettext as _ from django.db import transaction from model_utils.managers import InheritanceManager -from courseware.courses import get_course_about_section -from django.core.mail import send_mail - -from mitxmako.shortcuts import render_to_string -from xmodule.modulestore.django import modulestore -from xmodule.course_module import CourseDescriptor from course_modes.models import CourseMode +from courseware.courses import get_course_about_section +from mitxmako.shortcuts import render_to_string from student.views import course_from_id from student.models import CourseEnrollment from statsd import statsd -from .exceptions import * +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor + +from .exceptions import InvalidCartItem, PurchasedCallbackException log = logging.getLogger("shoppingcart") @@ -116,6 +118,7 @@ class Order(models.Model): self.bill_to_ccnum = ccnum self.bill_to_cardtype = cardtype self.processor_reply_dump = processor_reply_dump + # save these changes on the order, then we can tell when we are in an # inconsistent state self.save() @@ -124,6 +127,7 @@ class Order(models.Model): orderitems = OrderItem.objects.filter(order=self).select_subclasses() for item in orderitems: item.purchase_item() + # send confirmation e-mail subject = _("Order Payment Confirmation") message = render_to_string('emails/order_confirmation_email.txt', { @@ -195,6 +199,30 @@ class OrderItem(models.Model): """ raise NotImplementedError + @property + def single_item_receipt_template(self): + """ + The template that should be used when there's only one item in the order + """ + return 'shoppingcart/receipt.html' + + @property + def single_item_receipt_context(self): + """ + Extra variables needed to render the template specified in + `single_item_receipt_template` + """ + return {} + + @property + def additional_instruction_text(self): + """ + Individual instructions for this order item. + + Currently, only used for e-mails. + """ + return '' + class PaidCourseRegistration(OrderItem): """ @@ -311,6 +339,13 @@ class CertificateItem(OrderItem): course_enrollment = CourseEnrollment.objects.get(user=order.user, course_id=course_id) except ObjectDoesNotExist: course_enrollment = CourseEnrollment.create_enrollment(order.user, course_id, mode=mode) + + # do some validation on the enrollment mode + valid_modes = CourseMode.modes_for_course_dict(course_id) + if mode in valid_modes: + mode_info = valid_modes[mode] + else: + raise InvalidCartItem(_("Mode {mode} does not exist for {course_id}").format(mode=mode, course_id=course_id)) item, _created = cls.objects.get_or_create( order=order, user=order.user, @@ -321,8 +356,9 @@ class CertificateItem(OrderItem): item.status = order.status item.qty = 1 item.unit_cost = cost - item.line_desc = _("{mode} certificate for course {course_id}").format(mode=item.mode, - course_id=course_id) + course_name = course_from_id(course_id).display_name + item.line_desc = _("Certificate of Achievement, {mode_name} for course {course}").format(mode_name=mode_info.name, + course=course_name) item.currency = currency order.currency = currency order.save() @@ -336,3 +372,17 @@ class CertificateItem(OrderItem): self.course_enrollment.mode = self.mode self.course_enrollment.save() self.course_enrollment.activate() + + @property + def single_item_receipt_template(self): + if self.mode == 'verified': + return 'shoppingcart/verified_cert_receipt.html' + else: + return super(CertificateItem, self).single_item_receipt_template + + @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)) diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 5952668d8f..6162b325e3 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -97,13 +97,19 @@ def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='si if processor_hash(data) != returned_sig: raise CCProcessorSignatureException() - def render_purchase_form_html(cart): """ Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource """ - purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '') + return render_to_string('shoppingcart/cybersource_form.html', { + 'action': get_purchase_endpoint(), + 'params': get_signed_purchase_params(cart), + }) +def get_signed_purchase_params(cart): + return sign(get_purchase_params(cart)) + +def get_purchase_params(cart): total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() @@ -112,13 +118,11 @@ def render_purchase_form_html(cart): params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) - signed_param_dict = sign(params) - return render_to_string('shoppingcart/cybersource_form.html', { - 'action': purchase_endpoint, - 'params': signed_param_dict, - }) + return params +def get_purchase_endpoint(): + return settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '') def payment_accepted(params): """ diff --git a/lms/djangoapps/shoppingcart/tests/payment_fake.py b/lms/djangoapps/shoppingcart/tests/payment_fake.py new file mode 100644 index 0000000000..fa6f401904 --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/payment_fake.py @@ -0,0 +1,229 @@ +""" +Fake payment page for use in acceptance tests. +This view is enabled in the URLs by the feature flag `ENABLE_PAYMENT_FAKE`. + +Note that you will still need to configure this view as the payment +processor endpoint in order for the shopping cart to use it: + + settings.CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" + +You can configure the payment to indicate success or failure by sending a PUT +request to the view with param "success" +set to "success" or "failure". The view defaults to payment success. +""" + +from django.views.generic.base import View +from django.views.decorators.csrf import csrf_exempt +from django.http import HttpResponse, HttpResponseBadRequest +from mitxmako.shortcuts import render_to_response + + +# We use the same hashing function as the software under test, +# because it mainly uses standard libraries, and I want +# to avoid duplicating that code. +from shoppingcart.processors.CyberSource import processor_hash + + +class PaymentFakeView(View): + """ + Fake payment page for use in acceptance tests. + """ + + # We store the payment status to respond with in a class + # variable. In a multi-process Django app, this wouldn't work, + # since processes don't share memory. Since Lettuce + # runs one Django server process, this works for acceptance testing. + PAYMENT_STATUS_RESPONSE = "success" + + @csrf_exempt + def dispatch(self, *args, **kwargs): + """ + Disable CSRF for these methods. + """ + return super(PaymentFakeView, self).dispatch(*args, **kwargs) + + def post(self, request): + """ + Render a fake payment page. + + This is an HTML form that: + + * Triggers a POST to `postpay_callback()` on submit. + + * Has hidden fields for all the data CyberSource sends to the callback. + - Most of this data is duplicated from the request POST params (e.g. `amount` and `course_id`) + - Other params contain fake data (always the same user name and address. + - Still other params are calculated (signatures) + + * Serves an error page (HTML) with a 200 status code + if the signatures are invalid. This is what CyberSource does. + + Since all the POST requests are triggered by HTML forms, this is + equivalent to the CyberSource payment page, even though it's + served by the shopping cart app. + """ + if self._is_signature_valid(request.POST): + return self._payment_page_response(request.POST, '/shoppingcart/postpay_callback/') + + else: + return render_to_response('shoppingcart/test/fake_payment_error.html') + + def put(self, request): + """ + Set the status of payment requests to success or failure. + + Accepts one POST param "status" that can be either "success" + or "failure". + """ + new_status = request.body + + if not new_status in ["success", "failure"]: + return HttpResponseBadRequest() + + else: + # Configure all views to respond with the new status + PaymentFakeView.PAYMENT_STATUS_RESPONSE = new_status + return HttpResponse() + + @staticmethod + def _is_signature_valid(post_params): + """ + Return a bool indicating whether the client sent + us a valid signature in the payment page request. + """ + + # Calculate the fields signature + fields_sig = processor_hash(post_params.get('orderPage_signedFields')) + + # Retrieve the list of signed fields + signed_fields = post_params.get('orderPage_signedFields').split(',') + + # Calculate the public signature + hash_val = ",".join([ + "{0}={1}".format(key, post_params[key]) + for key in signed_fields + ]) + ",signedFieldsPublicSignature={0}".format(fields_sig) + + public_sig = processor_hash(hash_val) + + return public_sig == post_params.get('orderPage_signaturePublic') + + @classmethod + def response_post_params(cls, post_params): + """ + Calculate the POST params we want to send back to the client. + """ + resp_params = { + # Indicate whether the payment was successful + "decision": "ACCEPT" if cls.PAYMENT_STATUS_RESPONSE == "success" else "REJECT", + + # Reflect back whatever the client sent us, + # defaulting to `None` if a paramter wasn't received + "course_id": post_params.get('course_id'), + "orderAmount": post_params.get('amount'), + "ccAuthReply_amount": post_params.get('amount'), + "orderPage_transactionType": post_params.get('orderPage_transactionType'), + "orderPage_serialNumber": post_params.get('orderPage_serialNumber'), + "orderNumber": post_params.get('orderNumber'), + "orderCurrency": post_params.get('currency'), + "match": post_params.get('match'), + "merchantID": post_params.get('merchantID'), + + # Send fake user data + "billTo_firstName": "John", + "billTo_lastName": "Doe", + "billTo_street1": "123 Fake Street", + "billTo_state": "MA", + "billTo_city": "Boston", + "billTo_postalCode": "02134", + "billTo_country": "us", + + # Send fake data for other fields + "card_cardType": "001", + "card_accountNumber": "############1111", + "card_expirationMonth": "08", + "card_expirationYear": "2019", + "paymentOption": "card", + "orderPage_environment": "TEST", + "orderPage_requestToken": "unused", + "reconciliationID": "39093601YKVO1I5D", + "ccAuthReply_authorizationCode": "888888", + "ccAuthReply_avsCodeRaw": "I1", + "reasonCode": "100", + "requestID": "3777139938170178147615", + "ccAuthReply_reasonCode": "100", + "ccAuthReply_authorizedDateTime": "2013-08-28T181954Z", + "ccAuthReply_processorResponse": "100", + "ccAuthReply_avsCode": "X", + + # We don't use these signatures + "transactionSignature": "unused=", + "decision_publicSignature": "unused=", + "orderAmount_publicSignature": "unused=", + "orderNumber_publicSignature": "unused=", + "orderCurrency_publicSignature": "unused=", + } + + # Indicate which fields we are including in the signature + # Order is important + signed_fields = [ + 'billTo_lastName', 'orderAmount', 'course_id', + 'billTo_street1', 'card_accountNumber', 'orderAmount_publicSignature', + 'orderPage_serialNumber', 'orderCurrency', 'reconciliationID', + 'decision', 'ccAuthReply_processorResponse', 'billTo_state', + 'billTo_firstName', 'card_expirationYear', 'billTo_city', + 'billTo_postalCode', 'orderPage_requestToken', 'ccAuthReply_amount', + 'orderCurrency_publicSignature', 'orderPage_transactionType', + 'ccAuthReply_authorizationCode', 'decision_publicSignature', + 'match', 'ccAuthReply_avsCodeRaw', 'paymentOption', + 'billTo_country', 'reasonCode', 'ccAuthReply_reasonCode', + 'orderPage_environment', 'card_expirationMonth', 'merchantID', + 'orderNumber_publicSignature', 'requestID', 'orderNumber', + 'ccAuthReply_authorizedDateTime', 'card_cardType', 'ccAuthReply_avsCode' + ] + + # Add the list of signed fields + resp_params['signedFields'] = ",".join(signed_fields) + + # Calculate the fields signature + signed_fields_sig = processor_hash(resp_params['signedFields']) + + # Calculate the public signature + hash_val = ",".join([ + "{0}={1}".format(key, resp_params[key]) + for key in signed_fields + ]) + ",signedFieldsPublicSignature={0}".format(signed_fields_sig) + + resp_params['signedDataPublicSignature'] = processor_hash(hash_val) + + return resp_params + + def _payment_page_response(self, post_params, callback_url): + """ + Render the payment page to a response. This is an HTML form + that triggers a POST request to `callback_url`. + + The POST params are described in the CyberSource documentation: + http://apps.cybersource.com/library/documentation/dev_guides/HOP_UG/html/wwhelp/wwhimpl/js/html/wwhelp.htm + + To figure out the POST params to send to the callback, + we either: + + 1) Use fake static data (e.g. always send user name "John Doe") + 2) Use the same info we received (e.g. send the same `course_id` and `amount`) + 3) Dynamically calculate signatures using a shared secret + """ + + # Build the context dict used to render the HTML form, + # filling in values for the hidden input fields. + # These will be sent in the POST request to the callback URL. + context_dict = { + + # URL to send the POST request to + "callback_url": callback_url, + + # POST params embedded in the HTML form + 'post_params': self.response_post_params(post_params) + } + + return render_to_response('shoppingcart/test/fake_payment_page.html', context_dict) diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 42df02267d..eeec58f47a 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -4,11 +4,12 @@ Tests for the Shopping Cart Models from factory import DjangoModelFactory from mock import patch -from django.test import TestCase -from django.test.utils import override_settings from django.core import mail from django.conf import settings from django.db import DatabaseError +from django.test import TestCase +from django.test.utils import override_settings + from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE @@ -19,50 +20,54 @@ from course_modes.models import CourseMode from shoppingcart.exceptions import PurchasedCallbackException -class OrderTest(TestCase): +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class OrderTest(ModuleStoreTestCase): def setUp(self): self.user = UserFactory.create() - self.course_id = "test/course" + self.course_id = "org/test/Test_Course" + CourseFactory.create(org='org', number='test', display_name='Test Course') + for i in xrange(1, 5): + CourseFactory.create(org='org', number='test', display_name='Test Course {0}'.format(i)) self.cost = 40 def test_get_cart_for_user(self): # create a cart cart = Order.get_cart_for_user(user=self.user) # add something to it - CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor') # should return the same cart cart2 = Order.get_cart_for_user(user=self.user) self.assertEquals(cart2.orderitem_set.count(), 1) def test_cart_clear(self): cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') - CertificateItem.add_to_order(cart, 'test/course1', self.cost, 'verified') + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor') + CertificateItem.add_to_order(cart, 'org/test/Test_Course_1', self.cost, 'honor') self.assertEquals(cart.orderitem_set.count(), 2) cart.clear() self.assertEquals(cart.orderitem_set.count(), 0) def test_add_item_to_cart_currency_match(self): cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified', currency='eur') + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor', currency='eur') # verify that a new item has been added self.assertEquals(cart.orderitem_set.count(), 1) # verify that the cart's currency was updated self.assertEquals(cart.currency, 'eur') with self.assertRaises(InvalidCartItem): - CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified', currency='usd') + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor', currency='usd') # assert that this item did not get added to the cart self.assertEquals(cart.orderitem_set.count(), 1) def test_total_cost(self): cart = Order.get_cart_for_user(user=self.user) # add items to the order - course_costs = [('test/course1', 30), - ('test/course2', 40), - ('test/course3', 10), - ('test/course4', 20)] + course_costs = [('org/test/Test_Course_1', 30), + ('org/test/Test_Course_2', 40), + ('org/test/Test_Course_3', 10), + ('org/test/Test_Course_4', 20)] for course, cost in course_costs: - CertificateItem.add_to_order(cart, course, cost, 'verified') + CertificateItem.add_to_order(cart, course, cost, 'honor') self.assertEquals(cart.orderitem_set.count(), len(course_costs)) self.assertEquals(cart.total_cost, sum(cost for _course, cost in course_costs)) @@ -72,7 +77,7 @@ class OrderTest(TestCase): # CertificateItem, which is not quite good unit test form. Sorry. cart = Order.get_cart_for_user(user=self.user) self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) - CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + item = CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor') # course enrollment object should be created but still inactive self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) cart.purchase() @@ -83,12 +88,13 @@ class OrderTest(TestCase): self.assertEquals('Order Payment Confirmation', mail.outbox[0].subject) self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, mail.outbox[0].body) self.assertIn(unicode(cart.total_cost), mail.outbox[0].body) + self.assertIn(item.additional_instruction_text, mail.outbox[0].body) def test_purchase_item_failure(self): # once again, we're testing against the specific implementation of # CertificateItem cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor') with patch('shoppingcart.models.CertificateItem.save', side_effect=DatabaseError): with self.assertRaises(DatabaseError): cart.purchase() @@ -99,7 +105,7 @@ class OrderTest(TestCase): def purchase_with_data(self, cart): """ purchase a cart with billing information """ - CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor') cart.purchase( first='John', last='Smith', @@ -145,6 +151,7 @@ class OrderTest(TestCase): self.assertEqual(cart.bill_to_ccnum, '') self.assertEqual(cart.bill_to_cardtype, '') + class OrderItemTest(TestCase): def setUp(self): self.user = UserFactory.create() @@ -222,14 +229,26 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) -class CertificateItemTest(TestCase): +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class CertificateItemTest(ModuleStoreTestCase): """ Tests for verifying specific CertificateItem functionality """ def setUp(self): self.user = UserFactory.create() - self.course_id = "test/course" + self.course_id = "org/test/Test_Course" self.cost = 40 + CourseFactory.create(org='org', number='test', run='course', display_name='Test Course') + course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + course_mode.save() + course_mode = CourseMode(course_id=self.course_id, + mode_slug="verified", + mode_display_name="verified cert", + min_price=self.cost) + course_mode.save() def test_existing_enrollment(self): CourseEnrollment.enroll(self.user, self.course_id) @@ -240,3 +259,14 @@ class CertificateItemTest(TestCase): cart.purchase() enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id) self.assertEquals(enrollment.mode, u'verified') + + def test_single_item_template(self): + cart = Order.get_cart_for_user(user=self.user) + cert_item = CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + + self.assertEquals(cert_item.single_item_receipt_template, + 'shoppingcart/verified_cert_receipt.html') + + cert_item = CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor') + self.assertEquals(cert_item.single_item_receipt_template, + 'shoppingcart/receipt.html') diff --git a/lms/djangoapps/shoppingcart/tests/test_payment_fake.py b/lms/djangoapps/shoppingcart/tests/test_payment_fake.py new file mode 100644 index 0000000000..d59767908f --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/test_payment_fake.py @@ -0,0 +1,112 @@ +""" +Tests for the fake payment page used in acceptance tests. +""" + +from django.test import TestCase +from shoppingcart.processors.CyberSource import sign, verify_signatures, \ + CCProcessorSignatureException +from shoppingcart.tests.payment_fake import PaymentFakeView +from collections import OrderedDict + + +class PaymentFakeViewTest(TestCase): + """ + Test that the fake payment view interacts + correctly with the shopping cart. + """ + + CLIENT_POST_PARAMS = OrderedDict([ + ('match', 'on'), + ('course_id', 'edx/999/2013_Spring'), + ('amount', '25.00'), + ('currency', 'usd'), + ('orderPage_transactionType', 'sale'), + ('orderNumber', '33'), + ('merchantID', 'edx'), + ('djch', '012345678912'), + ('orderPage_version', 2), + ('orderPage_serialNumber', '1234567890'), + ]) + + def setUp(self): + super(PaymentFakeViewTest, self).setUp() + + # Reset the view state + PaymentFakeView.PAYMENT_STATUS_RESPONSE = "success" + + def test_accepts_client_signatures(self): + + # Generate shoppingcart signatures + post_params = sign(self.CLIENT_POST_PARAMS) + + # Simulate a POST request from the payment workflow + # page to the fake payment page. + resp = self.client.post( + '/shoppingcart/payment_fake', dict(post_params) + ) + + # Expect that the response was successful + self.assertEqual(resp.status_code, 200) + + # Expect that we were served the payment page + # (not the error page) + self.assertIn("Payment Form", resp.content) + + def test_rejects_invalid_signature(self): + + # Generate shoppingcart signatures + post_params = sign(self.CLIENT_POST_PARAMS) + + # Tamper with the signature + post_params['orderPage_signaturePublic'] = "invalid" + + # Simulate a POST request from the payment workflow + # page to the fake payment page. + resp = self.client.post( + '/shoppingcart/payment_fake', dict(post_params) + ) + + # Expect that we got an error + self.assertIn("Error", resp.content) + + def test_sends_valid_signature(self): + + # Generate shoppingcart signatures + post_params = sign(self.CLIENT_POST_PARAMS) + + # Get the POST params that the view would send back to us + resp_params = PaymentFakeView.response_post_params(post_params) + + # Check that the client accepts these + try: + verify_signatures(resp_params) + + except CCProcessorSignatureException: + self.fail("Client rejected signatures.") + + def test_set_payment_status(self): + + # Generate shoppingcart signatures + post_params = sign(self.CLIENT_POST_PARAMS) + + # Configure the view to fail payments + resp = self.client.put( + '/shoppingcart/payment_fake', + data="failure", content_type='text/plain' + ) + self.assertEqual(resp.status_code, 200) + + # Check that the decision is "REJECT" + resp_params = PaymentFakeView.response_post_params(post_params) + self.assertEqual(resp_params.get('decision'), 'REJECT') + + # Configure the view to accept payments + resp = self.client.put( + '/shoppingcart/payment_fake', + data="success", content_type='text/plain' + ) + self.assertEqual(resp.status_code, 200) + + # Check that the decision is "ACCEPT" + resp_params = PaymentFakeView.response_post_params(post_params) + self.assertEqual(resp_params.get('decision'), 'ACCEPT') diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 3ff3a25524..8c124901c1 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -50,6 +50,8 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): mode_display_name="honor cert", min_price=self.cost) self.course_mode.save() + self.verified_course_id = 'org/test/Test_Course' + CourseFactory.create(org='org', number='test', run='course1', display_name='Test Course') self.cart = Order.get_cart_for_user(self.user) def login_user(self): @@ -63,14 +65,14 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): PaidCourseRegistration.add_to_order(self.cart, self.course_id) self.login_user() resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) - self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.status_code, 400) self.assertIn(_('The course {0} is already in your cart.'.format(self.course_id)), resp.content) def test_add_course_to_cart_already_registered(self): CourseEnrollment.enroll(self.user, self.course_id) self.login_user() resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) - self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.status_code, 400) self.assertIn(_('You are already registered in course {0}.'.format(self.course_id)), resp.content) def test_add_nonexistent_course_to_cart(self): @@ -91,7 +93,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): def test_show_cart(self): self.login_user() reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) - cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_id, self.cost, 'honor') resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) self.assertEqual(resp.status_code, 200) @@ -110,7 +112,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): def test_clear_cart(self): self.login_user() PaidCourseRegistration.add_to_order(self.cart, self.course_id) - CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + CertificateItem.add_to_order(self.cart, self.verified_course_id, self.cost, 'honor') self.assertEquals(self.cart.orderitem_set.count(), 2) resp = self.client.post(reverse('shoppingcart.views.clear_cart', args=[])) self.assertEqual(resp.status_code, 200) @@ -120,7 +122,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): def test_remove_item(self, exception_log): self.login_user() reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) - cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_id, self.cost, 'honor') self.assertEquals(self.cart.orderitem_set.count(), 2) resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), {'id': reg_item.id}) @@ -166,7 +168,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): def test_show_receipt_404s(self): PaidCourseRegistration.add_to_order(self.cart, self.course_id) - CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + CertificateItem.add_to_order(self.cart, self.verified_course_id, self.cost, 'honor') self.cart.purchase() user2 = UserFactory.create() @@ -184,7 +186,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): @patch('shoppingcart.views.render_to_response', render_mock) def test_show_receipt_success(self): reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) - cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_id, self.cost, 'honor') self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') self.login_user() @@ -196,14 +198,14 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): ((template, context), _) = render_mock.call_args self.assertEqual(template, 'shoppingcart/receipt.html') self.assertEqual(context['order'], self.cart) - self.assertIn(reg_item.orderitem_ptr, context['order_items']) - self.assertIn(cert_item.orderitem_ptr, context['order_items']) + self.assertIn(reg_item, context['order_items']) + self.assertIn(cert_item, context['order_items']) self.assertFalse(context['any_refunds']) @patch('shoppingcart.views.render_to_response', render_mock) def test_show_receipt_success_refund(self): reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) - cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_id, self.cost, 'honor') self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') cert_item.status = "refunded" cert_item.save() @@ -213,9 +215,20 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) self.assertIn('40.00', resp.content) - ((template, context), _) = render_mock.call_args + ((template, context), _tmp) = render_mock.call_args self.assertEqual(template, 'shoppingcart/receipt.html') self.assertEqual(context['order'], self.cart) - self.assertIn(reg_item.orderitem_ptr, context['order_items']) - self.assertIn(cert_item.orderitem_ptr, context['order_items']) + self.assertIn(reg_item, context['order_items']) + self.assertIn(cert_item, context['order_items']) self.assertTrue(context['any_refunds']) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_show_receipt_success_custom_receipt_page(self): + cert_item = CertificateItem.add_to_order(self.cart, self.course_id, self.cost, 'honor') + self.cart.purchase() + self.login_user() + receipt_url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id]) + resp = self.client.get(receipt_url) + self.assertEqual(resp.status_code, 200) + ((template, _context), _tmp) = render_mock.call_args + self.assertEqual(template, cert_item.single_item_receipt_template) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 800c6077aa..9522d15298 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -13,3 +13,10 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: url(r'^remove_item/$', 'remove_item'), url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), ) + +if settings.MITX_FEATURES.get('ENABLE_PAYMENT_FAKE'): + from shoppingcart.tests.payment_fake import PaymentFakeView + urlpatterns += patterns( + 'shoppingcart.tests.payment_fake', + url(r'^payment_fake', PaymentFakeView.as_view()) + ) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index a2f88c9c94..fff8b22e08 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,5 +1,6 @@ import logging -from django.http import HttpResponse, HttpResponseRedirect, HttpResponseNotFound, HttpResponseForbidden, Http404 +from django.http import (HttpResponse, HttpResponseRedirect, HttpResponseNotFound, + HttpResponseBadRequest, HttpResponseForbidden, Http404) from django.utils.translation import ugettext as _ from django.views.decorators.http import require_POST from django.core.urlresolvers import reverse @@ -19,9 +20,10 @@ def add_course_to_cart(request, course_id): return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart')) cart = Order.get_cart_for_user(request.user) if PaidCourseRegistration.part_of_order(cart, course_id): - return HttpResponseNotFound(_('The course {0} is already in your cart.'.format(course_id))) + return HttpResponseBadRequest(_('The course {0} is already in your cart.'.format(course_id))) if CourseEnrollment.is_enrolled(user=request.user, course_id=course_id): - return HttpResponseNotFound(_('You are already registered in course {0}.'.format(course_id))) + return HttpResponseBadRequest(_('You are already registered in course {0}.'.format(course_id))) + try: PaidCourseRegistration.add_to_order(cart, course_id) except ItemNotFoundError: @@ -98,8 +100,18 @@ def show_receipt(request, ordernum): if order.user != request.user or order.status != 'purchased': raise Http404('Order not found!') - order_items = order.orderitem_set.all() + order_items = OrderItem.objects.filter(order=order).select_subclasses() any_refunds = any(i.status == "refunded" for i in order_items) - return render_to_response('shoppingcart/receipt.html', {'order': order, - 'order_items': order_items, - 'any_refunds': any_refunds}) + receipt_template = 'shoppingcart/receipt.html' + # we want to have the ability to override the default receipt page when + # there is only one item in the order + context = { + 'order': order, + 'order_items': order_items, + 'any_refunds': any_refunds, + } + + if order_items.count() == 1: + receipt_template = order_items[0].single_item_receipt_template + + return render_to_response(receipt_template, context) diff --git a/lms/djangoapps/verify_student/__init__.py b/lms/djangoapps/verify_student/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/verify_student/api.py b/lms/djangoapps/verify_student/api.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/verify_student/migrations/0001_initial.py b/lms/djangoapps/verify_student/migrations/0001_initial.py new file mode 100644 index 0000000000..65ffafc49b --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0001_initial.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'SoftwareSecurePhotoVerification' + db.create_table('verify_student_softwaresecurephotoverification', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('status', self.gf('model_utils.fields.StatusField')(default='created', max_length=100, no_check_for_status=True)), + ('status_changed', self.gf('model_utils.fields.MonitorField')(default=datetime.datetime.now, monitor=u'status')), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('name', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)), + ('face_image_url', self.gf('django.db.models.fields.URLField')(max_length=255, blank=True)), + ('photo_id_image_url', self.gf('django.db.models.fields.URLField')(max_length=255, blank=True)), + ('receipt_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)), + ('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), + ('submitted_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('reviewing_user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, related_name='photo_verifications_reviewed', null=True, to=orm['auth.User'])), + ('reviewing_service', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)), + ('error_msg', self.gf('django.db.models.fields.TextField')(blank=True)), + ('error_code', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)), + ('photo_id_key', self.gf('django.db.models.fields.TextField')(max_length=1024)), + )) + db.send_create_signal('verify_student', ['SoftwareSecurePhotoVerification']) + + + def backwards(self, orm): + # Deleting model 'SoftwareSecurePhotoVerification' + db.delete_table('verify_student_softwaresecurephotoverification') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'verify_student.softwaresecurephotoverification': { + 'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'}, + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}), + 'receipt_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}), + 'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}), + 'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}), + 'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['verify_student'] \ No newline at end of file diff --git a/lms/djangoapps/verify_student/migrations/__init__.py b/lms/djangoapps/verify_student/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py new file mode 100644 index 0000000000..f90ceec259 --- /dev/null +++ b/lms/djangoapps/verify_student/models.py @@ -0,0 +1,408 @@ +# -*- coding: utf-8 -*- +""" +Models for Student Identity Verification + +This is where we put any models relating to establishing the real-life identity +of a student over a period of time. Right now, the only models are the abstract +`PhotoVerification`, and its one concrete implementation +`SoftwareSecurePhotoVerification`. The hope is to keep as much of the +photo verification process as generic as possible. +""" +from datetime import datetime, timedelta +from hashlib import md5 +import base64 +import functools +import logging +import uuid + +import pytz + +from django.conf import settings +from django.db import models +from django.contrib.auth.models import User +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 +) + +log = logging.getLogger(__name__) + + +class VerificationException(Exception): + pass + + +def status_before_must_be(*valid_start_statuses): + """ + Helper decorator with arguments to make sure that an object with a `status` + attribute is in one of a list of acceptable status states before a method + is called. You could use it in a class definition like: + + @status_before_must_be("submitted", "approved", "denied") + def refund_user(self, user_id): + # Do logic here... + + If the object has a status that is not listed when the `refund_user` method + is invoked, it will throw a `VerificationException`. This is just to avoid + distracting boilerplate when looking at a Model that needs to go through a + workflow process. + """ + def decorator_func(fn): + @functools.wraps(fn) + def with_status_check(obj, *args, **kwargs): + if obj.status not in valid_start_statuses: + exception_msg = ( + u"Error calling {} {}: status is '{}', must be one of: {}" + ).format(fn, obj, obj.status, valid_start_statuses) + raise VerificationException(exception_msg) + return fn(obj, *args, **kwargs) + + return with_status_check + + return decorator_func + + +class PhotoVerification(StatusModel): + """ + Each PhotoVerification represents a Student's attempt to establish + their identity by uploading a photo of themselves and a picture ID. An + attempt actually has a number of fields that need to be filled out at + different steps of the approval process. While it's useful as a Django Model + for the querying facilities, **you should only edit a `PhotoVerification` + object through the methods provided**. Initialize them with a user: + + attempt = PhotoVerification(user=user) + + We track this attempt through various states: + + `created` + Initial creation and state we're in after uploading the images. + `ready` + The user has uploaded their images and checked that they can read the + images. There's a separate state here because it may be the case that we + don't actually submit this attempt for review until payment is made. + `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. + `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. + `denied` + The request has been denied. See `error_msg` for details on why. An + admin might later override this and change to `approved`, but the + student cannot re-open this attempt -- they have to create another + attempt and submit it instead. + + Because this Model inherits from StatusModel, we can also do things like:: + + attempt.status == PhotoVerification.STATUS.created + attempt.status == "created" + pending_requests = PhotoVerification.submitted.all() + """ + # We can make this configurable later... + DAYS_GOOD_FOR = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] + + ######################## Fields Set During Creation ######################## + # See class docstring for description of status states + STATUS = Choices('created', 'ready', 'submitted', '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 + # we always preserve what it was at the time they requested. We only copy + # this value during the mark_ready() step. Prior to that, you should be + # displaying the user's name from their user.profile.name. + name = models.CharField(blank=True, max_length=255) + + # Where we place the uploaded image files (e.g. S3 URLs) + face_image_url = models.URLField(blank=True, max_length=255) + photo_id_image_url = models.URLField(blank=True, max_length=255) + + # Randomly generated UUID so that external services can post back the + # results of checking a user's photo submission without use exposing actual + # user IDs or something too easily guessable. + receipt_id = models.CharField( + db_index=True, + default=uuid.uuid4, + max_length=255, + ) + + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True, db_index=True) + + ######################## Fields Set When Submitting ######################## + submitted_at = models.DateTimeField(null=True, db_index=True) + + #################### Fields Set During Approval/Denial ##################### + # If the review was done by an internal staff member, mark who it was. + reviewing_user = models.ForeignKey( + User, + db_index=True, + default=None, + null=True, + related_name="photo_verifications_reviewed" + ) + + # Mark the name of the service used to evaluate this attempt (e.g + # Software Secure). + reviewing_service = models.CharField(blank=True, max_length=255) + + # If status is "denied", this should contain text explaining why. + error_msg = models.TextField(blank=True) + + # Non-required field. External services can add any arbitrary codes as time + # goes on. We don't try to define an exhuastive list -- this is just + # capturing it so that we can later query for the common problems. + error_code = models.CharField(blank=True, max_length=50) + + class Meta: + abstract = True + ordering = ['-created_at'] + + ##### Methods listed in the order you'd typically call them + @classmethod + def user_is_verified(cls, user, earliest_allowed_date=None): + """ + Returns whether or not a user has satisfactorily proved their + identity. Depending on the policy, this can expire after some period of + time, so a user might have to renew periodically. + """ + earliest_allowed_date = ( + earliest_allowed_date or + datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR) + ) + return cls.objects.filter( + user=user, + status="approved", + created_at__gte=earliest_allowed_date + ).exists() + + @classmethod + def user_has_valid_or_pending(cls, user, earliest_allowed_date=None): + """ + TODO: eliminate duplication with user_is_verified + """ + valid_statuses = ['ready', 'submitted', 'approved'] + earliest_allowed_date = ( + earliest_allowed_date or + datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR) + ) + return cls.objects.filter( + user=user, + status__in=valid_statuses, + created_at__gte=earliest_allowed_date + ).exists() + + @classmethod + def active_for_user(cls, user): + """ + Return all PhotoVerifications that are still active (i.e. not + approved or denied). + + Should there only be one active at any given time for a user? Enforced + at the DB level? + """ + # 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') + if active_attempts: + return active_attempts[0] + else: + return None + + @status_before_must_be("created") + def upload_face_image(self, img): + raise NotImplementedError + + @status_before_must_be("created") + def upload_photo_id_image(self, img): + raise NotImplementedError + + @status_before_must_be("created") + def mark_ready(self): + """ + Mark that the user data in this attempt is correct. In order to + succeed, the user must have uploaded the necessary images + (`face_image_url`, `photo_id_image_url`). This method will also copy + their name from their user profile. Prior to marking it ready, we read + this value directly from their profile, since they're free to change it. + This often happens because people put in less formal versions of their + name on signup, but realize they want something different to go on a + formal document. + + Valid attempt statuses when calling this method: + `created` + + Status after method completes: `ready` + + Other fields that will be set by this method: + `name` + + State Transitions: + + `created` → `ready` + This is what happens when the user confirms to us that the pictures + 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.") + + # 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 + # attempt. + self.name = self.user.profile.name + self.status = "ready" + self.save() + + @status_before_must_be("ready", "submit") + def submit(self, reviewing_service=None): + if self.status == "submitted": + return + + if reviewing_service: + reviewing_service.submit(self) + self.submitted_at = datetime.now(pytz.UTC) + self.status = "submitted" + self.save() + + @status_before_must_be("submitted", "approved", "denied") + def approve(self, user_id=None, service=""): + """ + Approve this attempt. `user_id` + + Valid attempt statuses when calling this method: + `submitted`, `approved`, `denied` + + Status after method completes: `approved` + + Other fields that will be set by this method: + `reviewed_by_user_id`, `reviewed_by_service`, `error_msg` + + State Transitions: + + `submitted` → `approved` + This is the usual flow, whether initiated by a staff user or an + external validation service. + `approved` → `approved` + No-op. First one to approve it wins. + `denied` → `approved` + This might happen if a staff member wants to override a decision + made by an external service or another staff member (say, in + response to a support request). In this case, the previous values + of `reviewed_by_user_id` and `reviewed_by_service` will be changed + to whoever is doing the approving, and `error_msg` will be reset. + The only record that this record was ever denied would be in our + logs. This should be a relatively rare occurence. + """ + # If someone approves an outdated version of this, the first one wins + if self.status == "approved": + return + + self.error_msg = "" # reset, in case this attempt was denied before + self.error_code = "" # reset, in case this attempt was denied before + self.reviewing_user = user_id + self.reviewing_service = service + self.status = "approved" + self.save() + + @status_before_must_be("submitted", "approved", "denied") + def deny(self, + error_msg, + error_code="", + reviewing_user=None, + reviewing_service=""): + """ + Deny this attempt. + + Valid attempt statuses when calling this method: + `submitted`, `approved`, `denied` + + Status after method completes: `denied` + + Other fields that will be set by this method: + `reviewed_by_user_id`, `reviewed_by_service`, `error_msg`, + `error_code` + + State Transitions: + + `submitted` → `denied` + This is the usual flow, whether initiated by a staff user or an + external validation service. + `approved` → `denied` + This might happen if a staff member wants to override a decision + made by an external service or another staff member, or just correct + a mistake made during the approval process. In this case, the + previous values of `reviewed_by_user_id` and `reviewed_by_service` + will be changed to whoever is doing the denying. The only record + that this record was ever approved would be in our logs. This should + be a relatively rare occurence. + `denied` → `denied` + Update the error message and reviewing_user/reviewing_service. Just + lets you amend the error message in case there were additional + details to be made. + """ + self.error_msg = error_msg + self.error_code = error_code + self.reviewing_user = reviewing_user + self.reviewing_service = reviewing_service + self.status = "denied" + self.save() + + +class SoftwareSecurePhotoVerification(PhotoVerification): + """ + Model to verify identity using a service provided by Software Secure. Much + of the logic is inherited from `PhotoVerification`, but this class + encrypts the photos. + + Software Secure (http://www.softwaresecure.com/) is a remote proctoring + service that also does identity verification. A student uses their webcam + to upload two images: one of their face, one of a photo ID. Due to the + sensitive nature of the data, the following security precautions are taken: + + 1. The snapshot of their face is encrypted using AES-256 in CBC mode. All + face photos are encypted with the same key, and this key is known to + both Software Secure and edx-platform. + + 2. The snapshot of a user's photo ID is also encrypted using AES-256, but + the key is randomly generated using pycrypto's Random. Every verification + attempt has a new key. The AES key is then encrypted using a public key + provided by Software Secure. We store only the RSA-encryped AES key. + Since edx-platform does not have Software Secure's private RSA key, it + means that we can no longer even read photo ID. + + 3. The encrypted photos are base64 encoded and stored in an S3 bucket that + edx-platform does not have read access to. + """ + # This is a base64.urlsafe_encode(rsa_encrypt(photo_id_aes_key), ss_pub_key) + # So first we generate a random AES-256 key to encrypt our photo ID with. + # Then we RSA encrypt it with Software Secure's public key. Then we base64 + # encode that. The result is saved here. Actual expected length is 344. + photo_id_key = models.TextField(max_length=1024) + + @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 + + @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) + + # Upload this to S3 + + rsa_key = RSA.importKey( + settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"] + ) + rsa_cipher = PKCS1_OAEP.new(key) + rsa_encrypted_aes_key = rsa_cipher.encrypt(aes_key) diff --git a/lms/djangoapps/verify_student/ssencrypt.py b/lms/djangoapps/verify_student/ssencrypt.py new file mode 100644 index 0000000000..b2791501c9 --- /dev/null +++ b/lms/djangoapps/verify_student/ssencrypt.py @@ -0,0 +1,90 @@ +""" +NOTE: Anytime a `key` is passed into a function here, we assume it's a raw byte +string. It should *not* be a string representation of a hex value. In other +words, passing the `str` value of +`"32fe72aaf2abb44de9e161131b5435c8d37cbdb6f5df242ae860b283115f2dae"` is bad. +You want to pass in the result of calling .decode('hex') on that, so this instead: +"'2\xfer\xaa\xf2\xab\xb4M\xe9\xe1a\x13\x1bT5\xc8\xd3|\xbd\xb6\xf5\xdf$*\xe8`\xb2\x83\x11_-\xae'" + +The RSA functions take any key format that RSA.importKey() accepts, so... + +An RSA public key can be in any of the following formats: +* X.509 subjectPublicKeyInfo DER SEQUENCE (binary or PEM encoding) +* PKCS#1 RSAPublicKey DER SEQUENCE (binary or PEM encoding) +* OpenSSH (textual public key only) + +An RSA private key can be in any of the following formats: +* PKCS#1 RSAPrivateKey DER SEQUENCE (binary or PEM encoding) +* PKCS#8 PrivateKeyInfo DER SEQUENCE (binary or PEM encoding) +* OpenSSH (textual public key only) + +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 +import base64 + +from Crypto import Random +from Crypto.Cipher import AES, PKCS1_OAEP +from Crypto.PublicKey import RSA + + +def encrypt_and_encode(data, key): + return base64.urlsafe_b64encode(aes_encrypt(data, key)) + +def decode_and_decrypt(encoded_data, key): + return aes_decrypt(base64.urlsafe_b64decode(encoded_data), key) + +def aes_encrypt(data, key): + """ + Return a version of the `data` that has been encrypted to + """ + cipher = aes_cipher_from_key(key) + padded_data = pad(data) + return cipher.encrypt(padded_data) + +def aes_decrypt(encrypted_data, key): + cipher = aes_cipher_from_key(key) + padded_data = cipher.decrypt(encrypted_data) + return unpad(padded_data) + +def aes_cipher_from_key(key): + """ + Given an AES key, return a Cipher object that has `encrypt()` and + `decrypt()` methods. It will create the cipher to use CBC mode, and create + the initialization vector as Software Secure expects it. + """ + return AES.new(key, AES.MODE_CBC, generate_aes_iv(key)) + +def generate_aes_iv(key): + """ + Return the initialization vector Software Secure expects for a given AES + key (they hash it a couple of times and take a substring). + """ + return md5(key + md5(key).hexdigest()).hexdigest()[:AES.block_size] + +def random_aes_key(): + return Random.new().read(32) + +def pad(data): + bytes_to_pad = AES.block_size - len(data) % AES.block_size + return data + (bytes_to_pad * chr(bytes_to_pad)) + +def unpad(padded_data): + num_padded_bytes = ord(padded_data[-1]) + return padded_data[:-num_padded_bytes] + +def rsa_encrypt(data, rsa_pub_key_str): + """ + `rsa_pub_key` is a string with the public key + """ + key = RSA.importKey(rsa_pub_key_str) + cipher = PKCS1_OAEP.new(key) + encrypted_data = cipher.encrypt(data) + return encrypted_data + +def rsa_decrypt(data, rsa_priv_key_str): + key = RSA.importKey(rsa_priv_key_str) + cipher = PKCS1_OAEP.new(key) + return cipher.decrypt(data) diff --git a/lms/djangoapps/verify_student/tests/__init__.py b/lms/djangoapps/verify_student/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py new file mode 100644 index 0000000000..249e1f2653 --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from nose.tools import ( + assert_in, assert_is_none, assert_equals, assert_raises, assert_not_equals +) +from django.test import TestCase +from student.tests.factories import UserFactory +from verify_student.models import SoftwareSecurePhotoVerification, VerificationException + + +class TestPhotoVerification(TestCase): + + def test_state_transitions(self): + """Make sure we can't make unexpected status transitions. + + The status transitions we expect are:: + + created → ready → submitted → approved + ↑ ↓ + → denied + """ + user = UserFactory.create() + attempt = SoftwareSecurePhotoVerification(user=user) + 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) + assert_raises(VerificationException, attempt.deny) + + # Now let's fill in some values so that we can pass the mark_ready() call + attempt.face_image_url = "http://fake.edx.org/face.jpg" + attempt.photo_id_image_url = "http://fake.edx.org/photo_id.jpg" + attempt.mark_ready() + assert_equals(attempt.name, user.profile.name) # Move this to another test + assert_equals(attempt.status, "ready") + + # Once again, state transitions should fail here. We can't approve or + # deny anything until it's been placed into the submitted state -- i.e. + # the user has clicked on whatever agreements, or given payment, or done + # whatever the application requires before it agrees to process their + # attempt. + assert_raises(VerificationException, attempt.approve) + assert_raises(VerificationException, attempt.deny) + + # Now we submit + 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.deny("Could not read name on Photo ID") + assert_equals(attempt.status, "denied") + + diff --git a/lms/djangoapps/verify_student/tests/test_ssencrypt.py b/lms/djangoapps/verify_student/tests/test_ssencrypt.py new file mode 100644 index 0000000000..1e9978be7c --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_ssencrypt.py @@ -0,0 +1,78 @@ +import base64 + +from nose.tools import assert_equals + +from verify_student.ssencrypt import ( + aes_decrypt, aes_encrypt, encrypt_and_encode, decode_and_decrypt, + rsa_decrypt, rsa_encrypt, random_aes_key +) + +def test_aes(): + key_str = "32fe72aaf2abb44de9e161131b5435c8d37cbdb6f5df242ae860b283115f2dae" + key = key_str.decode("hex") + + def assert_roundtrip(text): + assert_equals(text, aes_decrypt(aes_encrypt(text, key), key)) + assert_equals( + text, + decode_and_decrypt( + encrypt_and_encode(text, key), + key + ) + ) + + assert_roundtrip("Hello World!") + assert_roundtrip("1234567890123456") # AES block size, padding corner case + # Longer string + assert_roundtrip("12345678901234561234567890123456123456789012345601") + assert_roundtrip("") + assert_roundtrip("\xe9\xe1a\x13\x1bT5\xc8") # Random, non-ASCII text + +def test_rsa(): + # Make up some garbage keys for testing purposes. + pub_key_str = """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1hLVjP0oV0Uy/+jQ+Upz +c+eYc4Pyflb/WpfgYATggkoQdnsdplmvPtQr85+utgqKPxOh+PvYGW8QNUzjLIu4 +5/GlmvBa82i1jRMgEAxGI95bz7j9DtH+7mnj+06zR5xHwT49jK0zMs5MjMaz5WRq +BUNkz7dxWzDrYJZQx230sPp6upy1Y5H5O8SnJVdghsh8sNciS4Bo4ZONQ3giBwxz +h5svjspz1MIsOoShjbAdfG+4VX7sVwYlw2rnQeRsMH5/xpnNeqtScyOMoz0N9UDG +dtRMNGa2MihAg7zh7/zckbUrtf+o5wQtlCJL1Kdj4EjshqYvCxzWnSM+MaYAjb3M +EQIDAQAB +-----END PUBLIC KEY-----""" + priv_key_str = """-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA1hLVjP0oV0Uy/+jQ+Upzc+eYc4Pyflb/WpfgYATggkoQdnsd +plmvPtQr85+utgqKPxOh+PvYGW8QNUzjLIu45/GlmvBa82i1jRMgEAxGI95bz7j9 +DtH+7mnj+06zR5xHwT49jK0zMs5MjMaz5WRqBUNkz7dxWzDrYJZQx230sPp6upy1 +Y5H5O8SnJVdghsh8sNciS4Bo4ZONQ3giBwxzh5svjspz1MIsOoShjbAdfG+4VX7s +VwYlw2rnQeRsMH5/xpnNeqtScyOMoz0N9UDGdtRMNGa2MihAg7zh7/zckbUrtf+o +5wQtlCJL1Kdj4EjshqYvCxzWnSM+MaYAjb3MEQIDAQABAoIBAQCviuA87fdfoOoS +OerrEacc20QDLaby/QoGUtZ2RmmHzY40af7FQ3PWFIw6Ca5trrTwxnuivXnWWWG0 +I2mCRM0Kvfgr1n7ubOW7WnyHTFlT3mnxK2Ov/HmNLZ36nO2cgkXA6/Xy3rBGMC9L +nUE1kSLzT/Fh965ntfS9zmVNNBhb6no0rVkGx5nK3vTI6kUmaa0m+E7KL/HweO4c +JodhN8CX4gpxSrkuwJ7IHEPYspqc0jInMYKLmD3d2g3BiOctjzFmaj3lV5AUlujW +z7/LVe5WAEaaxjwaMvwqrJLv9ogxWU3etJf22+Yy7r5gbPtqpqJrCZ5+WpGnUHws +3mMGP2QBAoGBAOc3pzLFgGUREVPSFQlJ06QFtfKYqg9fFHJCgWu/2B2aVZc2aO/t +Zhuoz+AgOdzsw+CWv7K0FH9sUkffk2VKPzwwwufLK3avD9gI0bhmBAYvdhS6A3nO +YM3W+lvmaJtFL00K6kdd+CzgRnBS9cZ70WbcbtqjdXI6+mV1WdGUTLhBAoGBAO0E +xhD4z+GjubSgfHYEZPgRJPqyUIfDH+5UmFGpr6zlvNN/depaGxsbhW8t/V6xkxsG +MCgic7GLMihEiUMx1+/snVs5bBUx7OT9API0d+vStHCFlTTe6aTdmiduFD4PbDsq +6E4DElVRqZhpIYusdDh7Z3fO2hm5ad4FfMlx65/RAoGAPYEfV7ETs06z9kEG2X6q +7pGaUZrsecRH8xDfzmKswUshg2S0y0WyCJ+CFFNeMPdGL4LKIWYnobGVvYqqcaIr +af5qijAQMrTkmQnXh56TaXXMijzk2czdEUQjOrjykIL5zxudMDi94GoUMqLOv+qF +zD/MuRoMDsPDgaOSrd4t/kECgYEAzwBNT8NOIz3P0Z4cNSJPYIvwpPaY+IkE2SyO +vzuYj0Mx7/Ew9ZTueXVGyzv6PfqOhJqZ8mNscZIlIyAAVWwxsHwRTfvPlo882xzP +97i1R4OFTYSNNFi+69sSZ/9utGjZ2K73pjJuj487tD2VK5xZAH9edTd2KeNSP7LB +MlpJNBECgYAmIswPdldm+G8SJd5j9O2fcDVTURjKAoSXCv2j4gEZzzfudpLWNHYu +l8N6+LEIVTMAytPk+/bImHvGHKZkCz5rEMSuYJWOmqKI92rUtI6fz5DUb3XSbrwT +3W+sdGFUK3GH1NAX71VxbAlFVLUetcMwai1+wXmGkRw6A7YezVFnhw== +-----END RSA PRIVATE KEY-----""" + aes_key_str = "32fe72aaf2abb44de9e161131b5435c8d37cbdb6f5df242ae860b283115f2dae" + + aes_key = aes_key_str.decode('hex') + + encrypted_aes_key = rsa_encrypt(aes_key, pub_key_str) + assert_equals(aes_key, rsa_decrypt(encrypted_aes_key, priv_key_str)) + + # Even though our AES key is only 32 bytes, RSA encryption will make it 256 + # bytes, and base64 encoding will blow that up to 344 + assert_equals(len(base64.urlsafe_b64encode(encrypted_aes_key)), 344) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py new file mode 100644 index 0000000000..71d94735ff --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -0,0 +1,36 @@ +""" + + +verify_student/start?course_id=MITx/6.002x/2013_Spring # create + /upload_face?course_id=MITx/6.002x/2013_Spring + /upload_photo_id + /confirm # mark_ready() + + ---> To Payment + +""" +import urllib + +from django.test import TestCase +from django.test.utils import override_settings + +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory + + +class StartView(TestCase): + + def start_url(course_id=""): + return "/verify_student/{0}".format(urllib.quote(course_id)) + + def test_start_new_verification(self): + """ + Test the case where the user has no pending `PhotoVerficiationAttempts`, + but is just starting their first. + """ + user = UserFactory.create(username="rusty", password="test") + self.client.login(username="rusty", password="test") + + def must_be_logged_in(self): + self.assertHttpForbidden(self.client.get(self.start_url())) + diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py new file mode 100644 index 0000000000..a4af53ba63 --- /dev/null +++ b/lms/djangoapps/verify_student/urls.py @@ -0,0 +1,38 @@ +from django.conf.urls import include, patterns, url +from django.views.generic import TemplateView + +from verify_student import views + +urlpatterns = patterns( + '', + url( + r'^show_requirements/(?P[^/]+/[^/]+/[^/]+)$', + views.show_requirements, + name="verify_student_show_requirements" + ), + + url( + r'^verify/(?P[^/]+/[^/]+/[^/]+)$', + views.VerifyView.as_view(), + name="verify_student_verify" + ), + + url( + r'^verified/(?P[^/]+/[^/]+/[^/]+)$', + views.VerifiedView.as_view(), + name="verify_student_verified" + ), + + url( + r'^create_order', + views.create_order, + name="verify_student_create_order" + ), + + url( + r'^show_verification_page/(?P[^/]+/[^/]+/[^/]+)$', + views.show_verification_page, + name="verify_student/show_verification_page" + ), + +) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py new file mode 100644 index 0000000000..85e7cb5309 --- /dev/null +++ b/lms/djangoapps/verify_student/views.py @@ -0,0 +1,221 @@ +""" + + +""" +import json +import logging +import decimal + +from mitxmako.shortcuts import render_to_response + +from django.conf import settings +from django.core.urlresolvers import reverse +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect +from django.shortcuts import redirect +from django.views.generic.base import View +from django.utils.decorators import method_decorator +from django.utils.translation import ugettext as _ +from django.utils.http import urlencode +from django.contrib.auth.decorators import login_required + +from course_modes.models import CourseMode +from student.models import CourseEnrollment +from student.views import course_from_id +from shoppingcart.models import Order, CertificateItem +from shoppingcart.processors.CyberSource import ( + get_signed_purchase_params, get_purchase_endpoint +) +from verify_student.models import SoftwareSecurePhotoVerification + +log = logging.getLogger(__name__) + +class VerifyView(View): + + @method_decorator(login_required) + def get(self, request, course_id): + """ + """ + # If the user has already been verified within the given time period, + # redirect straight to the payment -- no need to verify again. + if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user): + return redirect( + reverse('verify_student_verified', + kwargs={'course_id': course_id})) + elif CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified': + return redirect(reverse('dashboard')) + else: + # If they haven't completed a verification attempt, we have to + # restart with a new one. We can't reuse an older one because we + # won't be able to show them their encrypted photo_id -- it's easier + # bookkeeping-wise just to start over. + progress_state = "start" + + verify_mode = CourseMode.mode_for_course(course_id, "verified") + if course_id in request.session.get("donation_for_course", {}): + chosen_price = request.session["donation_for_course"][course_id] + else: + chosen_price = verify_mode.min_price + 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, + "purchase_endpoint": get_purchase_endpoint(), + "suggested_prices": [ + decimal.Decimal(price) + for price in verify_mode.suggested_prices.split(",") + ], + "currency": verify_mode.currency.upper(), + "chosen_price": chosen_price, + "min_price": verify_mode.min_price, + } + + return render_to_response('verify_student/photo_verification.html', context) + + +class VerifiedView(View): + """ + View that gets shown once the user has already gone through the + verification flow + """ + @method_decorator(login_required) + def get(self, request, course_id): + """ + Handle the case where we have a get request + """ + if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified': + return redirect(reverse('dashboard')) + verify_mode = CourseMode.mode_for_course(course_id, "verified") + if course_id in request.session.get("donation_for_course", {}): + chosen_price = request.session["donation_for_course"][course_id] + else: + chosen_price = verify_mode.min_price.format("{:g}") + + context = { + "course_id": course_id, + "course_name": course_from_id(course_id).display_name, + "purchase_endpoint": get_purchase_endpoint(), + "currency": verify_mode.currency.upper(), + "chosen_price": chosen_price, + } + return render_to_response('verify_student/verified.html', context) + + +@login_required +def create_order(request): + """ + Submit PhotoVerification and create a new Order for this verified cert + """ + if not SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user): + attempt = SoftwareSecurePhotoVerification(user=request.user) + attempt.status = "ready" + attempt.save() + + course_id = request.POST['course_id'] + donation_for_course = request.session.get('donation_for_course', {}) + current_donation = donation_for_course.get(course_id, decimal.Decimal(0)) + contribution = request.POST.get("contribution", donation_for_course.get(course_id, 0)) + try: + amount = decimal.Decimal(contribution).quantize(decimal.Decimal('.01'), rounding=decimal.ROUND_DOWN) + except decimal.InvalidOperation: + return HttpResponseBadRequest(_("Selected price is not valid number.")) + + if amount != current_donation: + donation_for_course[course_id] = amount + request.session['donation_for_course'] = donation_for_course + + verified_mode = CourseMode.modes_for_course_dict(course_id).get('verified', None) + + # make sure this course has a verified mode + if not verified_mode: + return HttpResponseBadRequest(_("This course doesn't support verified certificates")) + + if amount < verified_mode.min_price: + return HttpResponseBadRequest(_("No selected price or selected price is below minimum.")) + + # I know, we should check this is valid. All kinds of stuff missing here + cart = Order.get_cart_for_user(request.user) + cart.clear() + CertificateItem.add_to_order(cart, course_id, amount, 'verified') + + params = get_signed_purchase_params(cart) + + return HttpResponse(json.dumps(params), content_type="text/json") + + +@login_required +def show_requirements(request, course_id): + """ + Show the requirements necessary for + """ + if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified': + return redirect(reverse('dashboard')) + context = { + "course_id": course_id, + "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) + + +def show_verification_page(request): + pass + + +def enroll(user, course_id, mode_slug): + """ + Enroll the user in a course for a certain mode. + + This is the view you send folks to when they click on the enroll button. + This does NOT cover changing enrollment modes -- it's intended for new + enrollments only, and will just redirect to the dashboard if it detects + that an enrollment already exists. + """ + # If the user is already enrolled, jump to the dashboard. Yeah, we could + # do upgrades here, but this method is complicated enough. + if CourseEnrollment.is_enrolled(user, course_id): + return HttpResponseRedirect(reverse('dashboard')) + + available_modes = CourseModes.modes_for_course(course_id) + + # If they haven't chosen a mode... + if not mode_slug: + # Does this course support multiple modes of Enrollment? If so, redirect + # to a page that lets them choose which mode they want. + if len(available_modes) > 1: + return HttpResponseRedirect( + reverse('choose_enroll_mode', kwargs={'course_id': course_id}) + ) + # Otherwise, we use the only mode that's supported... + else: + mode_slug = available_modes[0].slug + + # If the mode is one of the simple, non-payment ones, do the enrollment and + # send them to their dashboard. + if mode_slug in ("honor", "audit"): + CourseEnrollment.enroll(user, course_id, mode=mode_slug) + return HttpResponseRedirect(reverse('dashboard')) + + if mode_slug == "verify": + if SoftwareSecurePhotoVerification.has_submitted_recent_request(user): + # Capture payment info + # Create an order + # 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: + mode = available_modes[0] + + elif len(available_modes) == 1: + if mode != available_modes[0]: + raise Exception() + + mode = available_modes[0] + + if mode == "honor": + CourseEnrollment.enroll(user, course_id) + return HttpResponseRedirect(reverse('dashboard')) + diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index c2c837c5b8..2182bbcad6 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -19,6 +19,7 @@ import logging logging.disable(logging.ERROR) import os from random import choice, randint +import string def seed(): @@ -94,6 +95,23 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True # Use the auto_auth workflow for creating users and logging them in MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True +# Enable fake payment processing page +MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True + +# Configure the payment processor to use the fake processing page +# Since both the fake payment page and the shoppingcart app are using +# the same settings, we can generate this randomly and guarantee +# that they are using the same secret. +RANDOM_SHARED_SECRET = ''.join( + choice(string.letters + string.digits + string.punctuation) + for x in range(250) +) + +CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = RANDOM_SHARED_SECRET +CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = "edx" +CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = "0123456789012345678901" +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" + # HACK # Setting this flag to false causes imports to not load correctly in the lettuce python files # We do not yet understand why this occurs. Setting this to true is a stopgap measure @@ -107,3 +125,10 @@ INSTALLED_APPS += ('lettuce.django',) LETTUCE_APPS = ('courseware',) LETTUCE_SERVER_PORT = choice(PORTS) if SAUCE.get('SAUCE_ENABLED') else randint(1024, 65535) LETTUCE_BROWSER = os.environ.get('LETTUCE_BROWSER', 'chrome') + +##################################################################### +# Lastly, see if the developer has any local overrides. +try: + from .private import * # pylint: disable=F0401 +except ImportError: + pass diff --git a/lms/envs/common.py b/lms/envs/common.py index 5d6cc5598d..230b90a203 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -161,11 +161,14 @@ MITX_FEATURES = { # basis in Studio) 'ENABLE_CHAT': False, + # Allow users to enroll with methods other than just honor code certificates + 'MULTIPLE_ENROLLMENT_ROLES' : False, + # Toggle the availability of the shopping cart page 'ENABLE_SHOPPING_CART': False, # Toggle storing detailed billing information - 'STORE_BILLING_INFO': False + 'STORE_BILLING_INFO': False, } # Used for A/B testing @@ -822,7 +825,10 @@ INSTALLED_APPS = ( 'notification_prefs', # Different Course Modes - 'course_modes' + 'course_modes', + + # Student Identity Verification + 'verify_student', ) ######################### MARKETING SITE ############################### @@ -837,6 +843,9 @@ MKTG_URL_LINK_MAP = { 'TOS': 'tos', 'HONOR': 'honor', 'PRIVACY': 'privacy_edx', + + # Verified Certificates + 'WHAT_IS_VERIFIED_CERT' : 'verified-certificate', } @@ -867,6 +876,11 @@ def enable_theme(theme_name): STATICFILES_DIRS.append((u'themes/%s' % theme_name, theme_root / 'static')) +################# Student Verification ################# +VERIFY_STUDENT = { + "DAYS_GOOD_FOR" : 365, # How many days is a verficiation good for? +} + ######################## CAS authentication ########################### if MITX_FEATURES.get('AUTH_USE_CAS'): diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 4bd0247694..51cd300af9 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -28,9 +28,9 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard) MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True -MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True MITX_FEATURES['ENABLE_SHOPPING_CART'] = True FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com" diff --git a/lms/envs/test.py b/lms/envs/test.py index a9c51310f6..94feffdf3e 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -155,6 +155,26 @@ OPENID_UPDATE_DETAILS_FROM_SREG = True OPENID_USE_AS_ADMIN_LOGIN = False OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] +###################### Payment ##############################3 +# Enable fake payment processing page +MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True + +# Configure the payment processor to use the fake processing page +# Since both the fake payment page and the shoppingcart app are using +# the same settings, we can generate this randomly and guarantee +# that they are using the same secret. +from random import choice +import string +RANDOM_SHARED_SECRET = ''.join( + choice(string.letters + string.digits + string.punctuation) + for x in range(250) +) + +CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = RANDOM_SHARED_SECRET +CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = "edx" +CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = "0123456789012345678901" +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" + ################################# CELERY ###################################### CELERY_ALWAYS_EAGER = True diff --git a/lms/static/images/icon-id.png b/lms/static/images/icon-id.png new file mode 100644 index 0000000000..c740799681 Binary files /dev/null and b/lms/static/images/icon-id.png differ diff --git a/lms/static/images/vcert-ribbon-s.png b/lms/static/images/vcert-ribbon-s.png new file mode 100644 index 0000000000..adedb7cd31 Binary files /dev/null and b/lms/static/images/vcert-ribbon-s.png differ diff --git a/lms/static/images/vcert-steps.png b/lms/static/images/vcert-steps.png new file mode 100644 index 0000000000..d2aa4c032d Binary files /dev/null and b/lms/static/images/vcert-steps.png differ diff --git a/lms/static/js/vendor/responsive-carousel/responsive-carousel.js b/lms/static/js/vendor/responsive-carousel/responsive-carousel.js new file mode 100644 index 0000000000..2c421a14e8 --- /dev/null +++ b/lms/static/js/vendor/responsive-carousel/responsive-carousel.js @@ -0,0 +1,4 @@ +/*! Responsive Carousel - v0.1.0 - 2013-07-15 +* https://github.com/filamentgroup/responsive-carousel +* Copyright (c) 2013 Filament Group, Inc.; Licensed MIT, GPL */ +(function(e){var t="carousel",n="."+t,r="data-transition",i=t+"-transitioning",s=t+"-item",o=t+"-active",u=t+"-item-prev",a=t+"-item-next",f=t+"-in",l=t+"-out",c=t+"-nav",h=function(){var e="webkit Moz O Ms".split(" "),t=!1,n;while(e.length){n=e.shift()+"Transition";if(n in document.documentElement.style!==undefined&&n in document.documentElement.style!=0){t=!0;break}}return t}(),p={_create:function(){e(this).trigger("beforecreate."+t)[t]("_init")[t]("_addNextPrev").trigger("create."+t)},_init:function(){var n=e(this).attr(r);n||(h=!1),e(this).addClass(t+" "+(n?t+"-"+n:"")+" ").children().addClass(s).first().addClass(o),e(this)[t]("_addNextPrevClasses")},_addNextPrevClasses:function(){var t=e(this).find("."+s),n=t.filter("."+o),r=n.next("."+s),i=n.prev("."+s);r.length||(r=t.first().not("."+o)),i.length||(i=t.last().not("."+o)),t.removeClass(u+" "+a),i.addClass(u),r.addClass(a)},next:function(){e(this)[t]("goTo","+1")},prev:function(){e(this)[t]("goTo","-1")},goTo:function(n){var i=e(this),u=i.attr(r),a=" "+t+"-"+u+"-reverse";e(this).find("."+s).removeClass([l,f,a].join(" "));var c=e(this).find("."+o),p=c.index(),d=(p<0?0:p)+1,v=typeof n=="number"?n:d+parseFloat(n),m=e(this).find(".carousel-item").eq(v-1),g=typeof n=="string"&&!parseFloat(n)||v>d?"":a;m.length||(m=e(this).find("."+s)[g.length?"last":"first"]()),h?i[t]("_transitionStart",c,m,g):(m.addClass(o),i[t]("_transitionEnd",c,m,g)),i.trigger("goto."+t,m)},update:function(){return e(this).children().not("."+c).addClass(s),e(this).trigger("update."+t)},_transitionStart:function(n,r,i){var s=e(this);r.one(navigator.userAgent.indexOf("AppleWebKit")>-1?"webkitTransitionEnd":"transitionend otransitionend",function(){s[t]("_transitionEnd",n,r,i)}),e(this).addClass(i),n.addClass(l),r.addClass(f)},_transitionEnd:function(n,r,i){e(this).removeClass(i),n.removeClass(l+" "+o),r.removeClass(f).addClass(o),e(this)[t]("_addNextPrevClasses")},_bindEventListeners:function(){var n=e(this).bind("click",function(r){var i=e(r.target).closest("a[href='#next'],a[href='#prev']");i.length&&(n[t](i.is("[href='#next']")?"next":"prev"),r.preventDefault())});return this},_addNextPrev:function(){return e(this).append("")[t]("_bindEventListeners")},destroy:function(){}};e.fn[t]=function(n,r,i,s){return this.each(function(){if(n&&typeof n=="string")return e.fn[t].prototype[n].call(this,r,i,s);if(e(this).data(t+"data"))return e(this);e(this).data(t+"active",!0),e.fn[t].prototype._create.call(this)})},e.extend(e.fn[t].prototype,p)})(jQuery),function(e){var t="carousel",n="."+t,r=t+"-no-transition",i=/iPhone|iPad|iPod/.test(navigator.platform)&&navigator.userAgent.indexOf("AppleWebKit")>-1,s={_dragBehavior:function(){var t=e(this),s,o={},u,a,f=function(t){var r=t.touches||t.originalEvent.touches,i=e(t.target).closest(n);t.type==="touchstart"&&(s={x:r[0].pageX,y:r[0].pageY}),r[0]&&r[0].pageX&&(o.touches=r,o.deltaX=r[0].pageX-s.x,o.deltaY=r[0].pageY-s.y,o.w=i.width(),o.h=i.height(),o.xPercent=o.deltaX/o.w,o.yPercent=o.deltaY/o.h,o.srcEvent=t)},l=function(t){f(t),o.touches.length===1&&e(t.target).closest(n).trigger("drag"+t.type.split("touch")[1],o)};e(this).bind("touchstart",function(t){e(this).addClass(r),l(t)}).bind("touchmove",function(e){f(e),l(e),i||(e.preventDefault(),window.scrollBy(0,-o.deltaY))}).bind("touchend",function(t){e(this).removeClass(r),l(t)})}};e.extend(e.fn[t].prototype,s),e(document).on("create."+t,n,function(){e(this)[t]("_dragBehavior")})}(jQuery),function(e){var t="carousel",n="."+t,r=t+"-active",i=t+"-item",s=function(e){return Math.abs(e)>4},o=function(e,n){var r=e.find("."+t+"-active"),s=r.prevAll().length+1,o=n<0,u=s+(o?1:-1),a=e.find("."+i).eq(u-1);return a.length||(a=e.find("."+i)[o?"first":"last"]()),[r,a]};e(document).on("dragmove",n,function(t,n){if(!s(n.deltaX))return;var r=o(e(this),n.deltaX);r[0].css("left",n.deltaX+"px"),r[1].css("left",n.deltaX<0?n.w+n.deltaX+"px":-n.w+n.deltaX+"px")}).on("dragend",n,function(n,i){if(!s(i.deltaX))return;var u=o(e(this),i.deltaX),a=Math.abs(i.deltaX)>45;e(this).one(navigator.userAgent.indexOf("AppleWebKit")?"webkitTransitionEnd":"transitionEnd",function(){u[0].add(u[1]).css("left",""),e(this).trigger("goto."+t,u[1])}),a?(u[0].removeClass(r).css("left",i.deltaX>0?i.w+"px":-i.w+"px"),u[1].addClass(r).css("left",0)):(u[0].css("left",0),u[1].css("left",i.deltaX>0?-i.w+"px":i.w+"px"))})}(jQuery),function(e,t){var n="carousel",r="."+n+"[data-paginate]",i=n+"-pagination",s=n+"-active-page",o={_createPagination:function(){var t=e(this).find("."+n+"-nav"),r=e(this).find("."+n+"-item"),s=e("
    "),o,u,a;t.find("."+i).remove(),r.each(function(t){o=t+1,u=e(this).attr("data-thumb"),a=o,u&&(a=""),s.append("
  1. "+a+"")}),u&&s.addClass(n+"-nav-thumbs"),t.addClass(n+"-nav-paginated").find("a").first().after(s)},_bindPaginationEvents:function(){e(this).bind("click",function(t){var r=e(t.target);t.target.nodeName==="IMG"&&(r=r.parent()),r=r.closest("a");var s=r.attr("href");r.closest("."+i).length&&s&&(e(this)[n]("goTo",parseFloat(s.split("#")[1])),t.preventDefault())}).bind("goto."+n,function(t,n){var r=n?e(n).index():0;e(this).find("ol."+i+" li").removeClass(s).eq(r).addClass(s)}).trigger("goto."+n)}};e.extend(e.fn[n].prototype,o),e(document).on("create."+n,r,function(){e(this)[n]("_createPagination")[n]("_bindPaginationEvents")}).on("update."+n,r,function(){e(this)[n]("_createPagination")})}(jQuery),function(e){e(function(){e(".carousel").carousel()})}(jQuery); diff --git a/lms/static/js/vendor/responsive-carousel/responsive-carousel.keybd.js b/lms/static/js/vendor/responsive-carousel/responsive-carousel.keybd.js new file mode 100644 index 0000000000..c6b5993124 --- /dev/null +++ b/lms/static/js/vendor/responsive-carousel/responsive-carousel.keybd.js @@ -0,0 +1,38 @@ +/* + * responsive-carousel keyboard extension + * https://github.com/filamentgroup/responsive-carousel + * + * Copyright (c) 2012 Filament Group, Inc. + * Licensed under the MIT, GPL licenses. + */ + +(function($) { + var pluginName = "carousel", + initSelector = "." + pluginName, + navSelector = "." + pluginName + "-nav a", + buffer, + keyNav = function( e ) { + clearTimeout( buffer ); + buffer = setTimeout(function() { + var $carousel = $( e.target ).closest( initSelector ); + + if( e.keyCode === 39 || e.keyCode === 40 ){ + $carousel[ pluginName ]( "next" ); + } + else if( e.keyCode === 37 || e.keyCode === 38 ){ + $carousel[ pluginName ]( "prev" ); + } + }, 200 ); + + if( 37 <= e.keyCode <= 40 ) { + e.preventDefault(); + } + }; + + // Touch handling + $( document ) + .on( "click", navSelector, function( e ) { + $( e.target )[ 0 ].focus(); + }) + .on( "keydown", navSelector, keyNav ); +}(jQuery)); diff --git a/lms/static/js/verify_student/CameraCapture.as b/lms/static/js/verify_student/CameraCapture.as new file mode 100644 index 0000000000..0f5d5acae1 --- /dev/null +++ b/lms/static/js/verify_student/CameraCapture.as @@ -0,0 +1,103 @@ +/** + * Simple Camera Capture application meant to be used where WebRTC is not supported + * (e.g. Safari, Internet Explorer, Opera). All orchestration is assumed to happen + * in JavaScript. The only function this application has is to capture a snapshot + * and allow a 640x480 PNG of that snapshot to be made available to the JS as a + * base64 encoded data URL. + * + * There are really only three methods: + * snap() freezes the video and returns a PNG file as a data URL string. You can + * assign this return value to an img's src attribute. + * reset() restarts the the video. + * imageDataUrl() returns the same thing as snap() -- + */ + +package +{ + import flash.display.BitmapData; + import flash.display.PNGEncoderOptions; + import flash.display.Sprite; + import flash.events.Event; + 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")] + public class CameraCapture extends Sprite + { + // We pick these values because that's captured by the WebRTC spec + private const VIDEO_WIDTH:int = 640; + private const VIDEO_HEIGHT:int = 480; + + private var camera:Camera; + private var video:Video; + private var b64EncodedImage:String = null; + + public function CameraCapture() + { + addEventListener(Event.ADDED_TO_STAGE, init); + } + + protected function init(e:Event):void { + camera = Camera.getCamera(); + camera.setMode(VIDEO_WIDTH, VIDEO_HEIGHT, 30); + + video = new Video(VIDEO_WIDTH, VIDEO_HEIGHT); + video.attachCamera(camera); + + addChild(video); + + ExternalInterface.addCallback("snap", snap); + ExternalInterface.addCallback("reset", reset); + ExternalInterface.addCallback("imageDataUrl", imageDataUrl); + + // Notify the container that the SWF is ready to be called. + ExternalInterface.call("setSWFIsReady"); + } + + public function snap():String { + // If we already have a b64 encoded image, just return that. The user + // is calling snap() multiple times in a row without reset() + if (b64EncodedImage) { + return imageDataUrl(); + } + + var bitmapData:BitmapData = new BitmapData(video.width, video.height); + bitmapData.draw(video); // Draw a snapshot of the video onto our bitmapData + video.attachCamera(null); // Stop capturing video + + // Convert to PNG + var pngBytes:ByteArray = new ByteArray(); + bitmapData.encode( + new Rectangle(0, 0, video.width, video.height), + new PNGEncoderOptions(), + pngBytes + ); + + // Convert to Base64 encoding of PNG + var b64Encoder:Base64Encoder = new Base64Encoder(); + b64Encoder.encodeBytes(pngBytes); + b64EncodedImage = b64Encoder.toString(); + + return imageDataUrl(); + } + + public function reset():String { + video.attachCamera(camera); + b64EncodedImage = null; + + return imageDataUrl(); + } + + public function imageDataUrl():String { + if (b64EncodedImage) { + return "data:image/png;base64," + b64EncodedImage; + } + return ""; + } + } +} + diff --git a/lms/static/js/verify_student/CameraCapture.swf b/lms/static/js/verify_student/CameraCapture.swf new file mode 100644 index 0000000000..d35e25fc26 Binary files /dev/null and b/lms/static/js/verify_student/CameraCapture.swf differ diff --git a/lms/static/js/verify_student/photocapture.js b/lms/static/js/verify_student/photocapture.js new file mode 100644 index 0000000000..a214cf06c3 --- /dev/null +++ b/lms/static/js/verify_student/photocapture.js @@ -0,0 +1,224 @@ +var onVideoFail = function(e) { + console.log('Failed to get camera access!', e); +}; + +// Returns true if we are capable of video capture (regardless of whether the +// user has given permission). +function initVideoCapture() { + window.URL = window.URL || window.webkitURL; + navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || + navigator.mozGetUserMedia || navigator.msGetUserMedia; + return !(navigator.getUserMedia == undefined); +} + +var submitToPaymentProcessing = function() { + var contribution_input = $("input[name='contribution']:checked") + var contribution = 0; + if(contribution_input.attr('id') == 'contribution-other') + { + contribution = $("input[name='contribution-other-amt']").val(); + } + else + { + contribution = contribution_input.val(); + } + var course_id = $("input[name='course_id']").val(); + var xhr = $.post( + "/verify_student/create_order", + { + "course_id" : course_id, + "contribution": contribution + }, + function(data) { + for (prop in data) { + $('').attr({ + type: 'hidden', + name: prop, + value: data[prop] + }).appendTo('#pay_form'); + } + } + ) + .done(function(data) { + $("#pay_form").submit(); + }) + .fail(function(jqXhr,text_status, error_thrown) { + alert(jqXhr.responseText); + }); +} + +function doResetButton(resetButton, captureButton, approveButton, nextButton) { + approveButton.removeClass('approved'); + nextButton.addClass('disabled'); + + captureButton.show(); + resetButton.hide(); + approveButton.hide(); +} + +function doApproveButton(approveButton, nextButton) { + approveButton.addClass('approved'); + nextButton.removeClass('disabled'); +} + +function doSnapshotButton(captureButton, resetButton, approveButton) { + captureButton.hide(); + resetButton.show(); + approveButton.show(); +} + + +function submitNameChange(event) { + event.preventDefault(); + var full_name = $('input[name="name"]').val(); + var xhr = $.post( + "/change_name", + { + "new_name" : full_name, + "rationale": "Want to match ID for ID Verified Certificates." + }, + function(data) { + $('#full-name').html(full_name); + } + ) + .fail(function(jqXhr,text_status, error_thrown) { + $('.message-copy').html(jqXhr.responseText); + }); + +} + +function initSnapshotHandler(names, hasHtml5CameraSupport) { + var name = names.pop(); + if (name == undefined) { + return; + } + + var video = $('#' + name + '_video'); + var canvas = $('#' + name + '_canvas'); + var image = $('#' + name + "_image"); + var captureButton = $("#" + name + "_capture_button"); + var resetButton = $("#" + name + "_reset_button"); + var approveButton = $("#" + name + "_approve_button"); + var nextButton = $("#" + name + "_next_button"); + var flashCapture = $("#" + name + "_flash"); + + var ctx = null; + if (hasHtml5CameraSupport) { + ctx = canvas[0].getContext('2d'); + } + var localMediaStream = null; + + function snapshot(event) { + if (hasHtml5CameraSupport) { + if (localMediaStream) { + ctx.drawImage(video[0], 0, 0); + image[0].src = canvas[0].toDataURL('image/png'); + } + else { + return false; + } + video[0].pause(); + } + else { + image[0].src = flashCapture[0].snap(); + } + + doSnapshotButton(captureButton, resetButton, approveButton); + return false; + } + + function reset() { + image[0].src = ""; + + if (hasHtml5CameraSupport) { + video[0].play(); + } + else { + flashCapture[0].reset(); + } + + doResetButton(resetButton, captureButton, approveButton, nextButton); + return false; + } + + function approve() { + doApproveButton(approveButton, nextButton) + return false; + } + + // Initialize state for this picture taker + captureButton.show(); + resetButton.hide(); + approveButton.hide(); + nextButton.addClass('disabled'); + + // Connect event handlers... + video.click(snapshot); + captureButton.click(snapshot); + resetButton.click(reset); + approveButton.click(approve); + + // If it's flash-based, we can just immediate initialize the next one. + // If it's HTML5 based, we have to do it in the callback from getUserMedia + // so that Firefox doesn't eat the second request. + if (hasHtml5CameraSupport) { + navigator.getUserMedia({video: true}, function(stream) { + video[0].src = window.URL.createObjectURL(stream); + localMediaStream = stream; + + // We do this in a recursive call on success because Firefox seems to + // simply eat the request if you stack up two on top of each other before + // the user has a chance to approve the first one. + initSnapshotHandler(names, hasHtml5CameraSupport); + }, onVideoFail); + } + else { + initSnapshotHandler(names, hasHtml5CameraSupport); + } + +} + +function objectTagForFlashCamera(name) { + return ''; +} + +$(document).ready(function() { + $(".carousel-nav").addClass('sr'); + $("#pay_button").click(submitToPaymentProcessing); + // prevent browsers from keeping this button checked + $("#confirm_pics_good").prop("checked", false) + $("#confirm_pics_good").change(function() { + $("#pay_button").toggleClass('disabled'); + }); + + + // add in handlers to add/remove the correct classes to the body + // when moving between steps + $('#face_next_button').click(function(){ + $('body').addClass('step-photos-id').removeClass('step-photos-cam') + }) + + $('#photo_id_next_button').click(function(){ + $('body').addClass('step-review').removeClass('step-photos-id') + }) + + // set up edit information dialog + $('#edit-name div[role="alert"]').hide(); + $('#edit-name .action-save').click(submitNameChange); + + var hasHtml5CameraSupport = initVideoCapture(); + + // If HTML5 WebRTC capture is not supported, we initialize jpegcam + if (!hasHtml5CameraSupport) { + $("#face_capture_div").html(objectTagForFlashCamera("face_flash")); + $("#photo_id_capture_div").html(objectTagForFlashCamera("photo_id_flash")); + } + + initSnapshotHandler(["photo_id", "face"], hasHtml5CameraSupport); + +}); diff --git a/lms/static/sass/_shame.scss b/lms/static/sass/_shame.scss index 99046666d5..1c9b997ebd 100644 --- a/lms/static/sass/_shame.scss +++ b/lms/static/sass/_shame.scss @@ -130,7 +130,13 @@ // ==================== // edx.org marketing site - registration iframe band-aid (poor form enough to isolate out) +.view-iframe, .view-iframe-content { + background: transparent !important; + overflow: hidden; +} + .view-partial-mktgregister { + background: transparent !important; // dimensions needed for course about page on marketing site .wrapper-view { @@ -169,6 +175,10 @@ &:hover .track { opacity: 1.0; } + + &.has-option-verified { + padding-top: 12px !important; + } } // already registered but course not started or registration is closed diff --git a/lms/static/sass/application.scss.mako b/lms/static/sass/application.scss.mako index fb7b4b1db2..2753edda0c 100644 --- a/lms/static/sass/application.scss.mako +++ b/lms/static/sass/application.scss.mako @@ -9,7 +9,8 @@ @import 'base/reset'; @import 'vendor/font-awesome'; - +@import 'vendor/responsive-carousel/responsive-carousel'; +@import 'vendor/responsive-carousel/responsive-carousel.slide'; // BASE *default edX offerings* // ==================== @@ -36,12 +37,19 @@ // base - assets @import 'base/font_face'; @import 'base/extends'; -@import 'base/animations'; +@import 'base/animations'; // base - starter @import 'base/base'; -// shared - course +// base - elements +@import 'elements/typography'; +@import 'elements/controls'; + +// base - specific views +@import 'views/verification'; + +// shared - course @import 'shared/forms'; @import 'shared/footer'; @import 'shared/header'; @@ -67,7 +75,7 @@ @import 'multicourse/help'; @import 'multicourse/edge'; -// applications +// applications @import 'discussion'; @import 'news'; diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index e2074f1976..9e25c863e0 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -1,13 +1,16 @@ +// lms - utilities - mixins and extends +// ==================== + // mixins - font sizing @mixin font-size($sizeValue: 16){ font-size: $sizeValue + px; - font-size: ($sizeValue/10) + rem; + // font-size: ($sizeValue/10) + rem; } // mixins - line height @mixin line-height($fontSize: auto){ line-height: ($fontSize*1.48) + px; - line-height: (($fontSize/10)*1.48) + rem; + // line-height: (($fontSize/10)*1.48) + rem; } // image-replacement hidden text @@ -38,21 +41,38 @@ @return #{$pxval / $base}em; } -// Line-height +// line-height @function lh($amount: 1) { @return $body-line-height * $amount; } +// ==================== -//----------------- -// Theme Mixin Styles -//----------------- +// theme mixin styles @mixin login_register_h1_style {} - @mixin footer_references_style {} // ==================== +// extends - UI - visual link +.ui-fake-link { + cursor: pointer; +} + +// extends - UI - functional disable +.ui-disabled { + pointer-events: none; + outline: none; +} + +// extends - UI - depth levels +.ui-depth0 { z-index: 0; } +.ui-depth1 { z-index: 10; } +.ui-depth2 { z-index: 100; } +.ui-depth3 { z-index: 1000; } +.ui-depth4 { z-index: 10000; } +.ui-depth5 { z-index: 100000; } + // extends -hidden elems - screenreaders .text-sr { border: 0; @@ -64,3 +84,39 @@ position: absolute; width: 1px; } + +// extends - UI - removes list styling/spacing when using uls, ols for navigation and less content-centric cases +.ui-no-list { + list-style: none; + margin: 0; + padding: 0; + text-indent: 0; + + li, dt, dd { + margin: 0; + padding: 0; + } +} + +// extends - text - image-replacement hidden text +.text-hide { + text-indent: 100%; + white-space: nowrap; + overflow: hidden; +} + +// extends - text - wrapping +.text-wrap { + text-wrap: wrap; + white-space: pre-wrap; + white-space: -moz-pre-wrap; + word-wrap: break-word; +} + +// extends - text - text overflow by ellipsis +.text-truncated { + @include box-sizing(border-box); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index ef9ac2a5b3..128c3f3850 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -1,19 +1,28 @@ +// base $baseline: 20px; +// ==================== + +// LAYOUT: grid $gw-column: 80px; $gw-gutter: 20px; - $fg-column: $gw-column; $fg-gutter: $gw-gutter; $fg-max-columns: 12; $fg-max-width: 1400px; $fg-min-width: 810px; -$sans-serif: 'Open Sans', $verdana; +// ==================== + +// FONTS +$sans-serif: 'Open Sans', $verdana, sans-serif; $monospace: Monaco, 'Bitstream Vera Sans Mono', 'Lucida Console', monospace; $body-font-family: $sans-serif; $serif: $georgia; +// ==================== + +// MISC: base fonts/colors $body-font-size: em(14); $body-line-height: golden-ratio(.875em, 1); $base-font-color: rgb(60,60,60); @@ -22,8 +31,21 @@ $base-font-color: rgb(60,60,60); $lighter-base-font-color: rgb(100,100,100); $very-light-text: #fff; +// ==================== + +// COLORS: misc. $white: rgb(255,255,255); +$white-t0: rgba($white, 0.125); +$white-t1: rgba($white, 0.25); +$white-t2: rgba($white, 0.5); +$white-t3: rgba($white, 0.75); + $black: rgb(0,0,0); +$black-t0: rgba($black, 0.125); +$black-t1: rgba($black, 0.25); +$black-t2: rgba($black, 0.5); +$black-t3: rgba($black, 0.75); + $blue: rgb(29,157,217); $pink: rgb(182,37,104); $yellow: rgb(255, 252, 221); @@ -35,9 +57,10 @@ $dark-gray: rgb(51, 51, 51); $border-color: rgb(200, 200, 200); $sidebar-color: rgb(246, 246, 246); $outer-border-color: rgb(170, 170, 170); + $green: rgb(37, 184, 90); -// old variables +// COLORS: old variables $light-gray: #ddd; $dark-gray: #333; @@ -60,24 +83,62 @@ $m-gray-d1: #7D7F83; $m-gray-d2: #707276; $m-gray-d3: #646668; $m-gray-d4: #050505; +$m-gray-t0: rgba($m-gray,0.125); +$m-gray-t1: rgba($m-gray,0.25); +$m-gray-t2: rgba($m-gray,0.50); +$m-gray-t3: rgba($m-gray,0.75); $m-blue: #1AA1DE; $m-blue-l1: #2BACE6; $m-blue-l2: #42B5E9; $m-blue-l3: #59BEEC; +$m-blue-l4: tint($m-blue,90%); +$m-blue-l5: tint($m-blue,95%); $m-blue-d1: #1790C7; $m-blue-d2: #1580B0; $m-blue-d3: #126F9A; $m-blue-d4: #0A4A67; +$m-blue-t0: rgba($m-blue,0.125); +$m-blue-t1: rgba($m-blue,0.25); +$m-blue-t2: rgba($m-blue,0.50); +$m-blue-t3: rgba($m-blue,0.75); $m-pink: #B52A67; $m-pink-l1: #CA2F73; $m-pink-l2: #D33F80; $m-pink-l3: #D7548E; +$m-pink-l4: tint($m-pink,75%); +$m-pink-l5: tint($m-pink,85%); $m-pink-d1: #A0255B; $m-pink-d2: #8C204F; $m-pink-d3: #771C44; +$m-green: rgb(0, 136, 1); +$m-green-s1: rgb(96, 188, 97); +$m-green-l1: tint($m-green,20%); +$m-green-l2: tint($m-green,40%); +$m-green-l3: tint($m-green,60%); +$m-green-l4: tint($m-green,90%); +$m-green-l5: tint($m-green,95%); +$m-green-d1: shade($m-green,20%); +$m-green-d2: shade($m-green,40%); +$m-green-d3: shade($m-green,60%); +$m-green-d4: shade($m-green,90%); +$m-green-t0: rgba($m-green,0.125); +$m-green-t1: rgba($m-green,0.25); +$m-green-t2: rgba($m-green,0.50); +$m-green-t3: rgba($m-green,0.75); + +// ==================== + +// shadows +$shadow: rgba(0,0,0,0.2); +$shadow-l1: rgba(0,0,0,0.1); +$shadow-l2: rgba(0,0,0,0.05); +$shadow-d1: rgba(0,0,0,0.4); + +// ==================== + $m-base-font-size: em(15); $base-font-color: rgb(60,60,60); @@ -98,57 +159,65 @@ $courseware-footer-border: none; $courseware-footer-shadow: none; $courseware-footer-margin: 0px; +// ==================== -// actions +// STATE: verified +$verified-color-lvl1: $m-green; +$verified-color-lvl2: $m-green-l1; +$verified-color-lvl3: $m-green-l2; +$verified-color-lvl4: $m-green-l3; +$verified-color-lvl5: $m-green-l4; + +// ==================== + +// ACTIONS: general $button-bg-image: linear-gradient(#fff 0%, rgb(250,250,250) 50%, rgb(237,237,237) 50%, rgb(220,220,220) 100%); $button-bg-color: transparent; $button-bg-hover-color: #fff; -// actions - primary +// ACTIONS: primary $action-primary-bg: $m-blue-d3; $action-primary-fg: $white; $action-primary-shadow: $m-blue-d4; -// focused - hover/active pseudo states +// ACTIONS: primary - focused - hover/active pseudo states $action-primary-focused-bg: $m-blue-d1; $action-primary-focused-fg: $white; -// current or active navigation item +// ACTIONS: primary - current or active navigation item $action-primary-active-bg: $m-blue; $action-primary-active-fg: $m-blue-d3; $action-primary-active-shadow: $m-blue-d2; $action-primary-active-focused-fg: $m-blue-d4; $action-primary-active-focused-shadow: $m-blue-d3; -// disabled +// ACTIONS: disabled $action-primary-disabled-bg: $m-gray-d3; $action-prmary-disabled-fg: $white; - - -// actions - secondary +// ACTIONS: secondary $action-secondary-bg: $m-pink; $action-secondary-fg: $white; $action-secondary-shadow: $m-pink-d2; -// focused - hover/active pseudo states +// ACTIONS: secondary - focused - hover/active pseudo states $action-secondary-focused-bg: $m-pink-l3; $action-secondary-focused-fg: $white; -// current or active navigation item +// ACTIONS: secondary - current or active navigation item $action-secondary-active-bg: $m-pink-l2; $action-secondary-active-fg: $m-pink-d1; $action-secondary-active-shadow: $m-pink-d1; $action-secondary-active-focused-fg: $m-pink-d3; $action-secondary-active-focused-shadow: $m-pink-d2; -// disabled +// ACTIONS: secondary - disabled $action-secondary-disabled-bg: $m-gray-d3; $action-secondary-disabled-fg: $white; +// ==================== - - +// MISC: visual horizontal rules $faded-hr-image-1: linear-gradient(180deg, rgba(200,200,200, 0) 0%, rgba(200,200,200, 1) 50%, rgba(200,200,200, 0)); $faded-hr-image-2: linear-gradient(180deg, rgba(200,200,200, 0) 0%, rgba(200,200,200, 1)); $faded-hr-image-3: linear-gradient(180deg, rgba(200,200,200, 1) 0%, rgba(200,200,200, 0)); @@ -156,54 +225,70 @@ $faded-hr-image-4: linear-gradient(180deg, rgba(240,240,240, 0) 0%, rgba(240,240 $faded-hr-image-5: linear-gradient(180deg, rgba(255,255,255, 0) 0%, rgba(255,255,255, 0.8) 50%, rgba(255,255,255, 0)); $faded-hr-image-6: linear-gradient(90deg, rgba(255,255,255, 0) 0%, rgba(255,255,255, 0.6) 50%, rgba(255,255,255, 0)); +// MISC: dashboard $dashboard-profile-header-image: linear-gradient(-90deg, rgb(255,255,255), rgb(245,245,245)); $dashboard-profile-header-color: transparent; $dashboard-profile-color: rgb(252,252,252); $dot-color: $light-gray; +// MISC: course assets $content-wrapper-bg: $white; $course-bg-color: #d6d6d6; $course-bg-image: url(../images/bg-texture.png); $account-content-wrapper-bg: shade($body-bg, 2%); - $course-profile-bg: rgb(245,245,245); $course-header-bg: rgba(255,255,255, 0.93); +// MISC: borders $border-color-1: rgb(190,190,190); $border-color-2: rgb(200,200,200); $border-color-3: rgb(100,100,100); $border-color-4: rgb(252,252,252); +$border-color-l1: $m-gray-l1; +$border-color-l2: $m-gray-l2; +$border-color-l3: $m-gray-l3; +$border-color-l4: $m-gray-l4; +// MISC: links and buttons $link-color: $blue; $link-color-d1: $m-blue-d2; $link-hover: $pink; $site-status-color: $pink; - $button-color: $blue; $button-archive-color: #eee; +// MISC: shadow, form, modal $shadow-color: $blue; +$form-bg-color: #fff; +$modal-bg-color: rgb(245,245,245); +// MISC: sidebar $sidebar-chapter-bg-top: rgba(255, 255, 255, .6); $sidebar-chapter-bg-bottom: rgba(255, 255, 255, 0); $sidebar-chapter-bg: #eee; $sidebar-active-image: linear-gradient(top, #e6e6e6, #d6d6d6); -$form-bg-color: #fff; -$modal-bg-color: rgb(245,245,245); - -//TOP HEADER IMAGE MARGIN +// TOP HEADER IMAGE MARGIN $header_image_margin: -69px; //FOOTER MARGIN $footer_margin: ($baseline/4) 0 ($baseline*1.5) 0; -//----------------- -// CSS BG Images -//----------------- +// ==================== + +// IMAGES: backgrounds $homepage-bg-image: '../images/homepage-bg.jpg'; $login-banner-image: url(../images/bg-banner-login.png); $register-banner-image: url(../images/bg-banner-register.png); $video-thumb-url: '../images/courses/video-thumb.jpg'; + +// ==================== + +// SPLINT: new standards + +// SPLINT: fonts +$f-serif: 'Bree Serif', Georgia, Cambria, 'Times New Roman', Times, serif; +$f-sans-serif: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif; +$f-monospace: 'Bitstream Vera Sans Mono', Consolas, Courier, monospace; diff --git a/lms/static/sass/elements/_controls.scss b/lms/static/sass/elements/_controls.scss new file mode 100644 index 0000000000..e7d884d146 --- /dev/null +++ b/lms/static/sass/elements/_controls.scss @@ -0,0 +1,218 @@ +// lms - elements - controls +// ==================== + +.btn { + @include box-sizing(border-box); + @include transition(color 0.25s ease-in-out, background 0.25s ease-in-out, box-shadow 0.25s ease-in-out); + display: inline-block; + cursor: pointer; + text-decoration: none; + + &:hover, &:active { + text-decoration: none; + } + + &.disabled, &[disabled] { + cursor: default; + pointer-events: none; + } +} + +.btn-pill { + border-radius: $baseline/5; +} + +.btn-rounded { + border-radius: ($baseline/2); +} + +.btn-edged { + border-radius: ($baseline/10); +} + +// primary button +.btn-primary { + @extend .t-weight3; + @extend .btn; + @extend .btn-edged; + border: none; + padding: ($baseline*0.75) $baseline; + text-align: center; + + &:hover, &:active { + + } + + &.current, &.active { + + &:hover, &:active { + + } + } + + &.disabled, &.is-disabled, &[disabled] { + background: $m-gray-l2; + color: $white-t3; + } +} + +// blue primary gray +.btn-primary-gray { + @extend .btn-primary; + box-shadow: 0 2px 1px 0 $m-gray-d2; + background: $m-gray; + color: $white; + + &:hover, &:active { + background: $m-gray-l1; + color: $white; + } + + &.current, &.active { + box-shadow: inset 0 2px 1px 1px $m-gray-d2; + background: $m-gray; + color: $m-gray-l1; + + &:hover, &:active { + box-shadow: inset 0 2px 1px 1px $m-gray-d3; + color: $m-gray-d3; + } + } + + &.disabled, &[disabled] { + box-shadow: none; + } +} + +// blue primary button +.btn-primary-blue { + @extend .btn-primary; + box-shadow: 0 2px 1px 0 $m-blue-d4; + background: $m-blue-d3; + color: $white; + + &:hover, &:active { + background: $m-blue-d1; + color: $white; + } + + &.current, &.active { + box-shadow: inset 0 2px 1px 1px $m-blue-d2; + background: $m-blue; + color: $m-blue-d2; + + &:hover, &:active { + box-shadow: inset 0 2px 1px 1px $m-blue-d3; + color: $m-blue-d3; + } + } + + &.disabled, &[disabled] { + box-shadow: none; + } +} + +// pink primary button +.btn-primary-pink { + @extend .btn-primary; + box-shadow: 0 2px 1px 0 $m-pink-d2; + background: $m-pink; + color: $white; + + &:hover, &:active { + background: $m-pink-l3; + color: $white; + } + + &.current, &.active { + box-shadow: inset 0 2px 1px 1px $m-pink-d1; + background: $m-pink-l2; + color: $m-pink-d1; + + &:hover, &:active { + box-shadow: inset 0 2px 1px 1px $m-pink-d2; + color: $m-pink-d3; + } + } + + &.disabled, &[disabled] { + box-shadow: none; + } +} + +// green primary button +.btn-primary-green { + @extend .btn-primary; + box-shadow: 0 2px 1px 0 $m-green-d2; + background: $m-green-d1; + color: $white; + + &:hover, &:active { + background: $m-green-s1; + color: $white; + } + + &.current, &.active { + box-shadow: inset 0 2px 1px 1px $m-green; + background: $m-green-l2; + color: $m-green; + + &:hover, &:active { + box-shadow: inset 0 2px 1px 1px $m-green-d1; + color: $m-green-d1; + } + } + + &.disabled, &[disabled] { + box-shadow: none; + } +} + +// disabled primary button - used for more manual approaches +.btn-primary-disabled { + background: $m-gray-l2; + color: $white-t3; + pointer-events: none; + cursor: default; + pointer-events: none; + box-shadow: none; +} + +// ==================== + + // application: canned actions + .btn { + font-family: $f-sans-serif; + } + + .btn-large { + @extend .t-action1; + @extend .t-weight3; + display: block; + padding:($baseline*0.75) ($baseline*1.5); + } + + .btn-avg { + @extend .t-action2; + @extend .t-weight3; + } + + .btn-blue { + @extend .btn-primary-blue; + margin-bottom: $baseline; + + + &:last-child { + margin-bottom: none; + } + } + + .btn-pink { + @extend .btn-primary-pink; + margin-bottom: $baseline; + + + &:last-child { + margin-bottom: none; + } + } diff --git a/lms/static/sass/elements/_typography.scss b/lms/static/sass/elements/_typography.scss new file mode 100644 index 0000000000..884f1651ef --- /dev/null +++ b/lms/static/sass/elements/_typography.scss @@ -0,0 +1,196 @@ +// lms - elements - typography +// ==================== + +// Scale - (6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 21, 24, 36, 48, 60, 72) + +// headings/titles +.t-title { + font-family: $f-sans-serif; +} + +.t-title1 { + @extend .t-title; + @include font-size(60); + @include line-height(60); +} + +.t-title2 { + @extend .t-title; + @include font-size(48); + @include line-height(48); +} + +.t-title3 { + @include font-size(36); + @include line-height(36); +} + +.t-title4 { + @extend .t-title; + @include font-size(24); + @include line-height(24); +} + +.t-title5 { + @extend .t-title; + @include font-size(18); + @include line-height(18); +} + +.t-title6 { + @extend .t-title; + @include font-size(16); + @include line-height(16); +} + +.t-title7 { + @extend .t-title; + @include font-size(14); + @include line-height(14); +} + +.t-title8 { + @extend .t-title; + @include font-size(12); + @include line-height(12); +} + +.t-title9 { + @extend .t-title; + @include font-size(11); + @include line-height(11); +} + +// ==================== + +// copy +.t-copy { + font-family: $f-sans-serif; +} + +.t-copy-base { + @extend .t-copy; + @include font-size(16); + @include line-height(16); +} + +.t-copy-lead1 { + @extend .t-copy; + @include font-size(18); + @include line-height(18); +} + +.t-copy-lead2 { + @extend .t-copy; + @include font-size(24); + @include line-height(24); +} + +.t-copy-sub1 { + @extend .t-copy; + @include font-size(14); + @include line-height(14); +} + +.t-copy-sub2 { + @extend .t-copy; + @include font-size(12); + @include line-height(12); +} + +// ==================== + +// actions/labels +.t-action1 { + @include font-size(18); + @include line-height(18); +} + +.t-action2 { + @include font-size(16); + @include line-height(16); +} + +.t-action3 { + @include font-size(14); + @include line-height(14); +} + +.t-action4 { + @include font-size(12); + @include line-height(12); +} + + +// ==================== + +// code +.t-code { + font-family: $f-monospace; +} + +// ==================== + +// icons +.t-icon1 { + @include font-size(48); +} + +.t-icon2 { + @include font-size(36); +} + +.t-icon3 { + @include font-size(24); +} + +.t-icon4 { + @include font-size(18); +} + +.t-icon5 { + @include font-size(16); +} + +.t-icon6 { + @include font-size(14); +} + +.t-icon7 { + @include font-size(12); +} + +.t-icon8 { + @include font-size(11); +} + +.t-icon9 { + @include font-size(10); +} + +.t-icon-solo { + @include line-height(0); +} + +// ==================== + +// typography weights +.t-weight1 { + font-weight: 300; +} + +.t-weight2 { + font-weight: 400; +} + +.t-weight3 { + font-weight: 500; +} + +.t-weight4 { + font-weight: 600; +} + +.t-weight5 { + font-weight: 700; +} diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 6b2e85b1c5..697f9fcfe1 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -1,3 +1,6 @@ +// lms - views - user/student dashboard +// ==================== + .dashboard { @include clearfix; padding: 60px 0 0 0; @@ -224,6 +227,7 @@ } } + // course listings .my-courses { float: left; margin: 0px; @@ -268,21 +272,30 @@ } } - .my-course { - clear: both; - @include clearfix; - margin-right: flex-gutter(); - margin-bottom: 50px; - padding-bottom: 50px; - border-bottom: 1px solid $border-color-1; - position: relative; - width: flex-grid(12); - z-index: 20; - @include transition(all 0.15s linear 0s); + // UI: course list + .listing-courses { + @extend .ui-no-list; - &:last-child { - margin-bottom: none; + .course-item { + margin-bottom: ($baseline*2.5); + border-bottom: 4px solid $border-color-l4; + padding-bottom: ($baseline*2.5); + + &:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: 0; + } } + } + + // UI: individual course item + .course { + @include box-sizing(box); + @include transition(all 0.15s linear 0s); + @include clearfix(); + @extend .ui-depth2; + position: relative; .cover { @include box-sizing(border-box); @@ -402,6 +415,51 @@ } } } + + // STATE: course mode - verified + &.verified { + @extend .ui-depth2; + margin-top: ($baseline*2.5); + border-top: 1px solid $verified-color-lvl3; + padding-top: ($baseline*1.25); + background: $white; + + // FIXME: bad, but needed selector! + .info > hgroup .date-block { + top: ($baseline*1.25); + } + + // course enrollment status message + .sts-enrollment { + display: inline-block; + position: absolute; + top: -28px; + right: ($baseline/2); + text-align: center; + + .label { + @extend .text-sr; + } + + .deco-graphic { + @extend .ui-depth3; + width: 40px; + position: absolute; + left: -30px; + top: -10px; + } + + .sts-enrollment-value { + @extend .ui-depth1; + @extend .copy-badge; + border-radius: 0; + padding: ($baseline/4) ($baseline/2) ($baseline/4) $baseline; + color: $white; + background: $verified-color-lvl3; + } + } + } + } .message-status { diff --git a/lms/static/sass/vendor/responsive-carousel/_responsive-carousel.scss b/lms/static/sass/vendor/responsive-carousel/_responsive-carousel.scss new file mode 100644 index 0000000000..cbd8d701de --- /dev/null +++ b/lms/static/sass/vendor/responsive-carousel/_responsive-carousel.scss @@ -0,0 +1,20 @@ +/* + * responsive-carousel + * https://github.com/filamentgroup/responsive-carousel + * + * Copyright (c) 2012 Filament Group, Inc. + * Licensed under the MIT, GPL licenses. + */ +.carousel { + width: 100%; + position: relative; +} +.carousel .carousel-item { + display: none; +} +.carousel .carousel-active { + display: block; +} +.carousel .carousel-nav:nth-child(2) { + display: none; +} diff --git a/lms/static/sass/vendor/responsive-carousel/_responsive-carousel.slide.scss b/lms/static/sass/vendor/responsive-carousel/_responsive-carousel.slide.scss new file mode 100644 index 0000000000..b902bc6575 --- /dev/null +++ b/lms/static/sass/vendor/responsive-carousel/_responsive-carousel.slide.scss @@ -0,0 +1,61 @@ +/* + * responsive-carousel + * https://github.com/filamentgroup/responsive-carousel + * + * Copyright (c) 2012 Filament Group, Inc. + * Licensed under the MIT, GPL licenses. +*/ +.carousel-slide { + position: relative; + overflow: hidden; + -webkit-transform: translate3d(0, 0, 0); + -moz-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + -o-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); +} +.carousel-slide .carousel-item { + position: absolute; + left: 100%; + top: 0; + width: 100%; /* necessary for non-active slides */ + display: block; /* overrides basic carousel styles */ + z-index: 1; + -webkit-transition: left .2s ease; + -moz-transition: left .2s ease; + -ms-transition: left .2s ease; + -o-transition: left .2s ease; + transition: left .2s ease; +} +.carousel-no-transition .carousel-item { + -webkit-transition: none; + -moz-transition: none; + -ms-transition: none; + -o-transition: none; + transition: none; +} +.carousel-slide .carousel-active { + left: 0; + position: relative; + z-index: 2; +} +.carousel-slide .carousel-in { + left: 0; +} +.carousel-slide-reverse .carousel-out { + left: 100%; +} +.carousel-slide .carousel-out, +.carousel-slide-reverse .carousel-in { + left: -100%; +} +.carousel-slide-reverse .carousel-item { + -webkit-transition: left .1s ease; + -moz-transition: left .1s ease; + -ms-transition: left .1s ease; + -o-transition: left .1s ease; + transition: left .1s ease; +} +.carousel-slide-reverse .carousel-active { + left: 0; +} diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss new file mode 100644 index 0000000000..ec974b194e --- /dev/null +++ b/lms/static/sass/views/_verification.scss @@ -0,0 +1,1846 @@ +// lms - views - verification flow +// ==================== + +// MISC: extends - type +// application: canned headings +.hd-lv1 { + @extend .t-title1; + @extend .t-weight1; + color: $m-gray-d4; + margin: 0 0 ($baseline*2) 0; +} + +.hd-lv2 { + @extend .t-title4; + @extend .t-weight1; + margin: 0 0 ($baseline*0.75) 0; + border-bottom: 1px solid $m-gray-l3; + padding-bottom: ($baseline/2); + color: $m-gray-d4; +} + +.hd-lv3 { + @extend .t-title6; + @extend .t-weight4; + margin: 0 0 ($baseline/4) 0; + color: $m-gray-d4; +} + +.hd-lv4 { + @extend .t-title6; + @extend .t-weight2; + margin: 0 0 $baseline 0; + color: $m-gray-d4; +} + +.hd-lv5 { + @extend .t-title7; + @extend .t-weight4; + margin: 0 0 ($baseline/4) 0; + color: $m-gray-d4; +} + +// application: canned copy +.copy-base { + @extend .t-copy-base; + color: $m-gray-d2; +} + +.copy-lead1 { + @extend .t-copy-lead2; + color: $m-gray; +} + +.copy-detail { + @extend .t-copy-sub1; + @extend .t-weight3; + color: $m-gray-d1; +} + +.copy-metadata { + @extend .t-copy-sub2; + color: $m-gray-d1; + + + .copy-metadata-value { + @extend .t-weight2; + } + + .copy-metadata-value { + @extend .t-weight4; + } +} + +// application: canned links +.copy-link { + border-bottom: 1px dotted transparent; + + &:hover, &:active { + border-color: $link-color-d1; + } +} + +.copy-badge { + @extend .t-title8; + @extend .t-weight5; + border-radius: ($baseline/5); + padding: ($baseline/2) $baseline; + text-transform: uppercase; +} + +// ==================== + +.btn-verify-primary { + @extend .btn-primary-green; +} + +// ==================== + +// MISC: extends - UI - window +.ui-window { + @include clearfix(); + border-radius: ($baseline/10); + box-shadow: 0 1px 2px 1px $shadow-l1; + margin-bottom: $baseline; + border: 1px solid $m-gray-l3; + background: $white; +} + +// ==================== + +// MISC: extends - UI - well +.ui-well { + box-shadow: inset 0 1px 2px 1px $shadow-l1; + padding: ($baseline*0.75) $baseline; +} + +// ==================== + +// MISC: expandable UI +.is-expandable { + + .title-expand { + + } + + .expandable-icon { + @include transition(all 0.25s ease-in-out 0s); + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + + .expandable-area { + // @include transition(opacity 0.25s ease-in-out 0s, height 0.25s ease-in-out 0s); + } + + // STATE: active + &.is-ready { + + .expandable-icon { + @include transform(rotate(-90deg)); + } + + .title-expand { + @extend .ui-fake-link; + color: $m-blue-d2; + + &:hover { + color: $m-blue; + } + } + +/* using jquery + .expandable-area { + visibility: none; + height: 0; + opacity: 0.0; + overflow-y: scroll; + } +*/ + } + + // STATE: expanded + &.is-expanded { + + .expandable-icon { + @include transform(rotate(0)); + @include transform-origin(50% 50%); + } + +/* + .expandable-area { + visibility: visible; + height: ($baseline*16); + opacity: 1.0; + } +*/ + } +} + +// ==================== + +// VIEW: all verification steps +.register.verification-process { + + // reset: box-sizing (making things so right its scary) + * { + @include box-sizing(border-box); + } + + // reset: typography + font-family: $sans-serif; + + // reset: typography - heading + h1, h2, h3, h4, h5 ,h6 { + @extend .t-title; + color: $m-gray-d4; + } + + // reset: typography - copy + p, ol, ul, dl, input, select, textarea { + font-family: $f-sans-serif; + color: $m-gray-d1; + } + + .copy { + + p, ul, li, dl, blockquote, input, select { + margin-bottom: ($baseline*0.75); + + &:last-child { + margin-bottom: 0; + } + } + } + + // reset: copy/text + + + // reset: forms + input { + font-style: normal; + font-weight: 400; + margin-right: ($baseline/5); + } + + label { + @extend .t-weight4; + font-family: $sans-serif; + font-style: normal; + color: $m-gray-d4; + } + + // HACK: nasty override due to our bad input/button styling + button, input[type="submit"], input[type="button"], button[type="submit"] { + @include font-size(16); + @extend .t-weight4; + text-transform: none; + text-shadow: none; + letter-spacing: 0; + } + + .checkbox label { + display: inline-block; + } + + input[type="checkbox"] { + width: auto; + } + + // reset: lists + .list-actions, .list-steps, .progress-steps, .list-controls, .list-fields, .list-help, .list-faq, .nav-wizard, .list-reqs, .list-faq, .review-tasks, .list-tips, .wrapper-photos, .field-group, .list-info { + @extend .ui-no-list; + } + + // ==================== + + // elements: layout + .content-wrapper { + background: $m-gray-l4; + padding-bottom: 0; + } + + .container { + background-color: $white; + padding: ($baseline*1.5) ($baseline*1.5) ($baseline*2) ($baseline*1.5); + } + + // ==================== + + // elements: common copy + .title { + @extend .t-title5; + @extend .t-weight1; + } + + .copy { + @extend .t-weight1; + } + + // ==================== + + // elements - controls + .action-primary { + @extend .btn-primary-blue; + border: none; + } + + .action-confirm { + @extend .btn-verify-primary; + border: none; + } + + // ==================== + + // elements: page depth + + // ==================== + + // UI: reports/tables + .wrapper-report { + + .report { + @extend .ui-window; + width: flex-grid(12,12); + border-color: $m-gray-t3; + border-collapse:collapse; + + tr { + text-align: left; + } + + td { + @extend .t-weight3; + vertical-align: middle; + padding: ($baseline*0.75) $baseline; + color: $m-gray-d3; + } + + th { + @extend .t-weight2; + padding: ($baseline/2) $baseline; + } + + thead { + + tr { + border-bottom: 1px solid $m-gray-l3; + } + + th[scope="col"] { + @extend .t-title7; + @extend .t-weight2; + vertical-align: middle; + color: $m-gray-l1; + background: $m-gray-l4; + } + } + + tbody { + + tr { + border-bottom: 1px solid $m-gray-l3; + } + } + + tfoot { + background: $m-blue-t0; + + th[scope="row"] { + + } + } + } + } + + // ==================== + + // UI: help + .help-item { + + .title { + @extend .hd-lv4; + margin-bottom: ($baseline/4); + } + + .copy { + @extend .copy-detail; + } + } + + // ==================== + + // UI : message + .wrapper-msg { + width: flex-grid(12,12); + margin: 0 auto ($baseline*1.5) auto; + border-bottom: ($baseline/4) solid $m-blue; + padding: $baseline ($baseline*1.5); + background: tint($m-blue, 95%); + + .msg-content, .msg-icon { + display: inline-block; + vertical-align: middle; + } + + .msg-content { + width: flex-grid(11,12); + + .title { + @extend .t-title6; + @extend .t-weight4; + } + + .copy { + @extend .t-copy-sub1; + } + } + + .msg-icon { + width: flex-grid(1,12); + @extend .t-icon2; + text-align: center; + color: $m-blue; + } + } + + // UI: error + .wrapper-msg-error { + border-bottom-color: $red; + background: tint($red, 95%); + + .msg-icon { + color: $red; + } + + .msg-content { + + .title { + color: $red; + } + } + } + + // UI: error + .wrapper-msg-activate { + border-bottom-color: $m-pink; + background: tint($m-pink, 95%); + + .msg-icon { + color: $m-pink; + } + + .msg-content { + + .title { + color: $m-pink; + } + } + } + + // ==================== + + // UI: inline messages + .msg-inline { + + &.msg-error { + + .copy, .copy p { + color: $red; + } + } + } + + // ==================== + + // UI: page header + .page-header { + width: flex-grid(12,12); + margin: 0 0 ($baseline/2) 0; + border-bottom: ($baseline/4) solid $m-gray-l4; + + .title { + @include clearfix(); + width: flex-grid(12,12); + + .wrapper-sts, .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; + + .sts-track-value { + @extend .copy-badge; + color: $white; + background: $verified-color-lvl3; + + .context { + margin-right: ($baseline/4); + opacity: 0.80; + color: $white; + } + } + } + + .sts { + @extend .t-title7; + display: block; + color: $m-gray; + } + + .sts-course { + @extend .t-title; + @include font-size(28); + @include line-height(28); + @extend .t-weight4; + display: block; + text-transform: none; + } + } + } + + // ==================== + + // UI : progress + .wrapper-progress { + position: relative; + margin-bottom: ($baseline*1.5); + } + + .progress-sts { + @include size(($baseline/4)); + @extend .ui-depth1; + position: absolute; + top: 43px; + left: 7%; + display: block; + width: 83%; + margin: 0 auto; + background: $m-gray-l4; + + .progress-sts-value { + width: 0%; + height: 100%; + display: block; + background: $verified-color-lvl4; + } + } + + .progress { + width: flex-grid(12,12); + margin: 0 auto; + border-bottom: ($baseline/4) solid $m-gray-l4; + + .progress-steps { + @include clearfix(); + position: relative; + top: ($baseline/4); + display: table; + width: 100%; + } + + .progress-step { + @extend .ui-depth2; + position: relative; + display: table-cell; + height: ($baseline*6); + padding: $baseline $baseline ($baseline*1.5) $baseline; + text-align: center; + + .wrapper-step-number, .step-number, .step-name { + display: block; + } + + .wrapper-step-number { + @include size(($baseline*2) ($baseline*2)); + margin: 0 auto ($baseline/2) auto; + border-radius: ($baseline*10); + border: ($baseline/5) solid $m-gray-l4; + background: $white; + + .step-number { + @extend .t-title7; + @extend .t-weight4; + @include line-height(0); + margin: 16px auto 0 auto; + color: $m-gray-l1; + } + } + + .step-name { + @extend .t-title7; + @extend .t-weight4; + color: $m-gray-l1; + } + + // confirmation step w/ icon + &.progress-step-icon { + + .step-number { + margin-top: ($baseline/2); + } + } + + // STATE: is completed + &.is-completed { + border-bottom: ($baseline/5) solid $verified-color-lvl3; + + .wrapper-step-number { + border-color: $verified-color-lvl3; + } + + .step-number, .step-name { + color: $m-gray-l3; + } + } + + // STATE: is current + &.is-current { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + } + + // ==================== + + // UI: slides + .carousel { + + // carousel item + .carousel-item { + opacity: 0.0; + } + + // STATE: active + .carousel-active { + opacity: 1.0; + } + + // indiv slides + .wrapper-view { + + } + + .view { + width: flex-grid(12,12); + + > .title { + @extend .hd-lv2; + color: $m-blue-d1; + } + + .instruction { + @extend .t-copy-lead1; + margin-bottom: $baseline; + } + } + + .wrapper-task { + @include clearfix(); + width: flex-grid(12,12); + margin: $baseline 0; + + .wrapper-help { + float: right; + width: flex-grid(6,12); + padding: 0 $baseline; + + .help { + margin-bottom: ($baseline*1.5); + + &:last-child { + margin-bottom: 0; + } + + .title { + @extend .hd-lv3; + } + + .copy { + @extend .copy-detail; + } + + // help - general list + .list-help { + margin-top: ($baseline/2); + + .help-item { + margin-bottom: ($baseline/4); + border-bottom: 1px solid $m-gray-l4; + padding-bottom: ($baseline/4); + + &:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: 0; + } + } + } + + // help - faq + .list-faq { + margin-bottom: $baseline; + } + } + } + + .task { + @extend .ui-window; + float: left; + width: flex-grid(6,12); + margin-right: flex-gutter(); + } + + .controls { + padding: ($baseline*0.75) $baseline; + background: $m-gray-l4; + + .list-controls { + position: relative; + } + + .control { + position: absolute; + + .action { + @extend .btn-primary-blue; + padding: ($baseline/2) ($baseline*0.75); + + *[class^="icon-"] { + @extend .t-icon4; + padding: ($baseline*.25) ($baseline*.5); + display: block; + } + } + + // STATE: hidden + &.is-hidden { + visibility: hidden; + } + + // STATE: shown + &.is-shown { + visibility: visible; + } + + // STATE: approved + &.approved { + + .action { + @extend .btn-verify-primary; + } + } + } + + // control - redo + .control-redo { + position: absolute; + left: ($baseline/2); + } + + // control - take/do + .control-do { + left: 45%; + } + + // control - approve + .control-approve { + position: absolute; + right: ($baseline/2); + } + } + + .msg { + @include clearfix(); + margin-top: ($baseline*2); + + .copy { + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .list-actions { + position: relative; + top: -($baseline/2); + float: left; + width: flex-grid(4,12); + text-align: right; + + .action-retakephotos a { + @extend .btn-primary-blue; + @include font-size(14); + padding: ($baseline/2) ($baseline*.75); + } + } + } + + .msg-followup { + border-top: ($baseline/10) solid $m-gray-t0; + padding-top: $baseline; + } + } + + // indiv slides - photo + #wrapper-facephoto { + + } + + // indiv slides - ID + #wrapper-idphoto { + + } + + // indiv slides - review + #wrapper-review { + + .review-task { + margin-bottom: ($baseline*1.5); + padding: ($baseline*0.75) $baseline; + border-radius: ($baseline/10); + background: $m-gray-l4; + + &:last-child { + margin-bottom: 0; + } + + > .title { + @extend .hd-lv3; + } + + .copy { + @extend .copy-base; + + strong { + @extend .t-weight5; + color: $m-gray-d4; + } + } + } + + // individual task - photos + .review-task-photos { + + .wrapper-photos { + @include clearfix(); + width: flex-grid(12,12); + margin: $baseline 0; + + .wrapper-photo { + float: left; + width: flex-grid(6,12); + margin-right: flex-gutter(); + + &:last-child { + margin-right: 0; + } + + .placeholder-photo { + @extend .ui-window; + padding: ($baseline*0.75) $baseline; + + img { + display: block; + width: 100%; + margin: 0 auto; + } + } + } + + .help-tips { + + .title { + @extend .hd-lv5; + } + + .copy { + @extend .copy-detail; + } + + // help - general list + .list-tips { + + .tip { + margin-bottom: ($baseline/4); + border-bottom: 1px solid $m-gray-t0; + padding-bottom: ($baseline/4); + + &:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: 0; + } + } + } + } + } + } + + // individual task - name + .review-task-name { + @include clearfix(); + + .copy { + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .list-actions { + position: relative; + top: -($baseline); + float: left; + width: flex-grid(4,12); + text-align: right; + + .action-editname a { + @extend .btn-primary-blue; + @include font-size(14); + padding: ($baseline/2) ($baseline*.75); + } + } + } + + // individual task - contribution + .review-task-contribution { + + .list-fields { + @include clearfix(); + margin: $baseline 0; + + .field { + float: left; + margin-right: ($baseline/2); + padding: ($baseline/2) ($baseline*0.75); + background: $m-gray-t0; + + &:last-child { + margin-right: 0; + } + } + + .field-group-other { + + .contribution-option { + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + padding: 3px 0; + + &:last-child { + margin-right: 0; + } + } + + .contribution-option-other1 label, .contribution-option-other2 label { + @extend .text-sr; + } + } + } + } + } + + } + + // UI: camera states + .cam { + + .placeholder-cam { + position: relative; + width: 95%; + height: 375px; + margin: ($baseline/2) auto; + background: $m-blue-l5; + + // placeholders + .placeholder-art { + opacity: 0.7; + z-index: 100; + + .copy { + position: absolute; + top: 40%; + margin: 0 40px; + text-align: center; + } + } + + // previously defined in HTML + video, canvas { + position: relative; + display: block; + @include size(100% 100%); + z-index: 500; + } + } + + .controls { + height: ($baseline*4); + } + } + + // ==================== + + // UI: deco - divider + .deco-divider { + position: relative; + display: block; + margin: $baseline 0 ($baseline*2) 0; + border-top: ($baseline/5) solid $m-gray-l4; + + .copy { + @extend .t-copy-lead1; + @extend .t-weight4; + position: absolute; + top: -($baseline*1.25); + left: 45%; + padding: ($baseline/2) ($baseline*1.5); + background: white; + text-align: center; + color: $m-gray-l2; + } + } + + + // ==================== + + // UI: nav - wizard + .nav-wizard { + @extend .ui-well; + @include clearfix; + width: flex-grid(12,12); + border-radius: ($baseline/10); + background: $m-gray-l4; + + .help-inline, .wizard-steps { + + } + + .help-inline { + @extend .t-copy-sub1; + float: left; + width: flex-grid(6,12); + margin: ($baseline*0.75) flex-gutter() 0 0; + } + + .wizard-steps { + float: right; + width: flex-grid(6,12); + text-align: right; + } + + // STATE: is ready + &.is-ready { + background: $m-blue-l4; + } + + &.is-not-ready { + background: $m-gray-l4; + + .action-primary { + @extend .btn-primary-disabled; + } + } + } + + // ==================== + + // UI: forms - payment + .contribution-options { + + .contribution-option { + border-radius: ($baseline/5); + + .label, label, input { + display: inline-block; + vertical-align: middle; + } + + .label, label { + margin-bottom: 0; + padding: 6px 0; + } + + input { + margin-right: ($baseline/4); + } + + .deco-denomination, .label-value, .denomination-name { + display: inline-block; + vertical-align: middle; + } + + .deco-denomination { + } + + .label-value { + @extend .t-weight4; + } + + .denomination-name { + @include font-size(14); + color: $m-gray-l1; + } + + // specific fields + #contribution-other-amt { + width: ($baseline*4.5); + padding: ($baseline/4) ($baseline/2); + } + } + } + + // ==================== + + // help - faq + .list-faq { + + .faq-question { + @extend .hd-lv3; + border-bottom: 1px solid $m-gray-l4; + padding-bottom: ($baseline/4); + } + + .faq-answer { + margin-bottom: ($baseline*1.25); + } + } + + // ==================== + + // UI: main content + .wrapper-content-main { + + } + + .content-main { + width: flex-grid(12,12); + + > .title { + @extend .hd-lv2; + color: $m-blue-d1; + } + + .instruction { + @extend .t-copy-lead1; + margin-bottom: $baseline; + } + } + + // ==================== + + // UI: supplemental content + .wrapper-content-supplementary { + margin: ($baseline*1.5) 0; + border-top: ($baseline/4) solid $m-gray-l4; + } + + .content-supplementary { + width: flex-grid(12,12); + + .list-help { + @include clearfix(); + + .help-item { + width: flex-grid(4,12); + float: left; + margin-right: flex-gutter(); + + &:last-child { + margin-right: 0 + } + } + } + } + + // ==================== + + // VIEW: select a track + &.step-select-track { + + .sts-track { + @extend .text-sr; + } + + .form-register-choose { + @include clearfix(); + width: flex-grid(12,12); + margin: $baseline 0; + + .deco-divider { + width: flex-grid(8,12); + float: left; + } + } + + .register-choice, .help-register { + float: left; + } + + .register-choice { + width: flex-grid(8,12); + margin: 0 flex-gutter() $baseline 0; + border-top: ($baseline/4) solid $m-gray-d4; + padding: $baseline ($baseline*1.5); + background: $m-gray-l4; + + &:last-child { + margin-bottom: none; + } + + .wrapper-copy, .list-actions { + display: inline-block; + vertical-align: middle; + } + + .wrapper-copy { + width: flex-grid(8,8); + } + + .list-actions { + width: flex-grid(8,8); + text-align: right; + } + + .title { + @extend .t-title5; + @extend .t-weight5; + margin-bottom: ($baseline/2); + } + + .copy { + @extend .t-copy-base; + } + + .action-select input { + @extend .t-weight4; + padding: ($baseline/2) ($baseline*0.75); + } + } + + .register-choice-audit { + border-color: $m-blue-d1; + + .wrapper-copy { + width: flex-grid(5,8); + } + + .list-actions { + width: flex-grid(3,8); + } + + .action-select input { + @extend .btn-primary-blue; + } + } + + .register-choice-certificate { + border-color: $verified-color-lvl3; + position: relative; + + .deco-ribbon { + position: absolute; + top: -($baseline*1.5); + right: $baseline; + display: block; + width: ($baseline*3); + height: ($baseline*4); + background: transparent url('../images/vcert-ribbon-s.png') no-repeat 0 0; + } + + .list-actions { + margin: ($baseline/2) 0; + border-top: ($baseline/10) solid $m-gray-t1; + padding-top: $baseline; + } + + .action-intro, .action-select { + display: inline-block; + vertical-align: middle; + } + + .action-intro { + @extend .copy-detail; + width: flex-grid(3,8); + text-align: left; + } + + .action-select { + width: flex-grid(5,8); + } + + .action-select input { + @extend .btn-verify-primary; + } + + .action-intro { + + } + + // extra register options/info + .title-expand { + @extend .t-copy-sub1; + font-weight: 500 !important; + display: inline-block; + margin: 0; + } + + .expandable-area { + margin: $baseline 0; + } + } + + .help-register { + width: flex-grid(4,12); + + .title { + @extend .hd-lv4; + @extend .t-weight4; + margin-top: $baseline; + margin-bottom: ($baseline/2); + } + + .copy { + @extend .copy-detail; + } + } + + // progress indicator + .progress-sts-value { + width: 0%; + } + + // contribution selection + .field-certificate-contribution { + margin: $baseline 0; + + .label { + @extend .hd-lv4; + @extend .t-weight4; + margin-bottom: ($baseline/2); + } + } + + .contribution-options { + @include clearfix(); + margin: $baseline 0; + + .field { + float: left; + margin-right: ($baseline/2); + padding: ($baseline/2) ($baseline*0.75); + background: $m-gray-t0; + + input { + width: auto; + } + + &:last-child { + margin-right: 0; + } + } + + #contribution-other-amt { + width: ($baseline*4.5); + padding: ($baseline/4) ($baseline/2); + } + + .field-group-other { + + .contribution-option { + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + padding: 1px 0; + + &:last-child { + margin-right: 0; + } + } + + .contribution-option-other1 label, .contribution-option-other2 label { + @extend .text-sr; + } + } + } + } + + // VIEW: requirements + &.step-requirements { + + // progress nav + .progress .progress-step { + + // STATE: is current + &#progress-step0 { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + + // progress indicator + .progress-sts-value { + width: 0%; + } + + .list-reqs { + @include clearfix(); + width: flex-grid(12,12); + + .req { + @extend .ui-window; + width: flex-grid(4,12); + min-height: ($baseline*15); + float: left; + margin-right: flex-gutter(); + border-color: $verified-color-lvl4; + text-align: center; + + &:last-child { + margin-right: 0; + } + + .title { + @extend .t-title5; + @extend .t-weight4; + padding: $baseline; + border-bottom: 1px solid $verified-color-lvl4; + background: $verified-color-lvl5; + + } + + .placeholder-art { + position: relative; + display: inline-block; + margin: $baseline 0 ($baseline/2) 0; + padding: $baseline; + background: $verified-color-lvl3; + border-radius: ($baseline*10); + + *[class^="icon"] { + @extend .t-icon1; + color: $white; + } + + .icon-over, .icon-under { + position: relative; + } + + .icon-under { + @extend .ui-depth1; + } + + .icon-over { + @extend .ui-depth2; + @extend .t-icon5; + position: absolute; + left: 24px; + top: 34px; + background: $verified-color-lvl3; + padding: 3px 5px; + } + } + + .copy { + padding: ($baseline/2) $baseline; + } + + .copy-super, .copy-sub { + display: block; + } + + .copy-super { + @extend .t-copy-base; + margin-bottom: ($baseline/2); + color: $verified-color-lvl1; + } + + .copy-sub { + @extend .t-copy-sub2; + } + + .actions { + padding: ($baseline/2) $baseline; + } + } + + // specific requirements + .req-activate { + float: left; + text-align: center; + border-color: $m-pink-l3; + + .title { + @extend .t-title4; + @extend .t-weight4; + border-bottom-color: $m-pink-l3; + background: tint($m-pink, 95%); + color: $m-pink; + } + + .placeholder-art { + background: $m-pink-l1; + } + + .copy-super { + @extend .t-copy-lead1; + color: $m-pink; + } + + .copy-sub { + @extend .t-copy-base; + } + } + + // CASE: account not activated + &.account-not-activated { + + .req { + width: flex-grid(3,12); + min-height: ($baseline*18); + } + } + } + } + + // VIEW: take and review photos + &.step-photos { + + } + + // VIEW: take cam photo + &.step-photos-cam { + + // progress nav + .progress .progress-step { + + // STATE: is completed + &#progress-step0 { + border-bottom: ($baseline/5) solid $verified-color-lvl3; + + .wrapper-step-number { + border-color: $verified-color-lvl3; + } + + .step-number, .step-name { + color: $m-gray-l3; + } + } + + // STATE: is current + &#progress-step1 { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + + // progress indicator + .progress-sts-value { + width: 14%; + } + } + + // VIEW: take id photo + &.step-photos-id { + + // progress nav + .progress .progress-step { + + // STATE: is completed + &#progress-step0, &#progress-step1 { + border-bottom: ($baseline/5) solid $verified-color-lvl3; + + .wrapper-step-number { + border-color: $verified-color-lvl3; + } + + .step-number, .step-name { + color: $m-gray-l3; + } + } + + // STATE: is current + &#progress-step2 { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + + // progress indicator + .progress-sts-value { + width: 36%; + } + } + + // VIEW: review photos + &.step-review { + + .nav-wizard { + + .help-inline { + width: flex-grid(4,12); + margin-top: 0 + } + + .wizard-steps { + float: right; + width: flex-grid(8,12); + + .wizard-step { + width: flex-grid(4,8); + margin-right: flex-gutter(); + display: inline-block; + vertical-align: middle; + + &:last-child { + margin-right: 0; + } + } + } + + .step-match { + + label { + @extend .t-copy-sub1; + } + } + + .step-proceed { + + } + } + + // progress nav + .progress .progress-step { + + // STATE: is completed + &#progress-step0, &#progress-step1, &#progress-step2 { + border-bottom: ($baseline/5) solid $verified-color-lvl3; + + .wrapper-step-number { + border-color: $verified-color-lvl3; + } + + .step-number, .step-name { + color: $m-gray-l3; + } + } + + // STATE: is current + &#progress-step3 { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + + // progress indicator + .progress-sts-value { + width: 55%; + } + } + + // VIEW: confirmation/receipt + &.step-confirmation { + + // progress nav + .progress .progress-step { + + // STATE: is completed + &#progress-step0, &#progress-step1, &#progress-step2, &#progress-step3, &#progress-step4 { + border-bottom: ($baseline/5) solid $verified-color-lvl3; + + .wrapper-step-number { + border-color: $verified-color-lvl3; + } + + .step-number, .step-name { + color: $m-gray-l3; + } + } + + // STATE: is current + &#progress-step5 { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + + // progress indicator + .progress-sts-value { + width: 100%; + } + + // information elements + .list-info { + + .info-item { + margin-bottom: ($baseline*1.5); + border-bottom: ($baseline/4) solid $m-gray-l4; + padding-bottom: ($baseline*1.5); + + &:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: none; + } + + > .title { + @extend .hd-lv3; + margin-bottom: $baseline; + } + + .copy { + @extend .copy-base; + margin-bottom: $baseline; + } + } + } + + // information - course + .course-info { + + .options { + + .action-course { + @extend .btn-primary-blue; + @include font-size(14); + padding: 5px 10px; + } + } + + .course-actions { + + .action-dashboard { + @extend .btn-primary-blue; + } + } + } + + // information - verification + .verification-info { + + } + + // information - payment + .payment-info { + + } + + // UI: table/report + .wrapper-report { + + .report { + + } + } + } +} + +// STATE: already verified +.register.is-verified { + + .nav-wizard .price-value { + @extend .t-weight4; + @include font-size(16); + margin-top: 18px; + color: $m-blue-d1; + } + + // progress nav + .progress .progress-step { + + // STATE: is completed + &#progress-step1 { + border-bottom: ($baseline/5) solid $verified-color-lvl3; + + .wrapper-step-number { + border-color: $verified-color-lvl3; + } + + .step-number, .step-name { + color: $m-gray-l3; + } + } + + // STATE: is current + &#progress-step2 { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + + // progress indicator + .progress-sts { + width: 75%; + left: 13%; + } + + .progress-sts-value { + width: 32% !important; + } +} diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 4d22e6959c..6abcd2998a 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -47,7 +47,12 @@ $('#class_enroll_form').on('ajax:complete', function(event, xhr) { if(xhr.status == 200) { - location.href = "${reverse('dashboard')}"; + if (xhr.responseText == "") { + location.href = "${reverse('dashboard')}"; + } + else { + location.href = xhr.responseText; + } } else if (xhr.status == 403) { location.href = "${reverse('register_user')}?course_id=${course.id}&enrollment_action=enroll"; } else { @@ -95,7 +100,6 @@ %else: ${_("Register for {course.display_number_with_default}").format(course=course) | h} -
    %endif diff --git a/lms/templates/courseware/mktg_coming_soon.html b/lms/templates/courseware/mktg_coming_soon.html index 80aeb42c37..0c90362019 100644 --- a/lms/templates/courseware/mktg_coming_soon.html +++ b/lms/templates/courseware/mktg_coming_soon.html @@ -10,7 +10,7 @@ <%block name="title">${_("About {course_id}").format(course_id=course_id)} -<%block name="bodyclass">view-partial-mktgregister +<%block name="bodyclass">view-iframe-content view-partial-mktgregister <%block name="headextra"> diff --git a/lms/templates/courseware/mktg_course_about.html b/lms/templates/courseware/mktg_course_about.html index 5903a453bc..d9de7fede4 100644 --- a/lms/templates/courseware/mktg_course_about.html +++ b/lms/templates/courseware/mktg_course_about.html @@ -10,7 +10,7 @@ <%block name="title">${_("About {course_number}").format(course_number=course.display_number_with_default) | h} -<%block name="bodyclass">view-partial-mktgregister +<%block name="bodyclass">view-iframe-content view-partial-mktgregister <%block name="headextra"> @@ -27,7 +27,12 @@ $('#class_enroll_form').on('ajax:complete', function(event, xhr) { if(xhr.status == 200) { - window.top.location.href = "${reverse('dashboard')}"; + if (xhr.responseText != "") { + window.top.location.href = xhr.responseText; + } + else { + window.top.location.href = "${reverse('dashboard')}"; + } } else if (xhr.status == 403) { window.top.location.href = "${reverse('register_user')}?course_id=${course.id}&enrollment_action=enroll"; } else { @@ -52,7 +57,7 @@
    ${_("You Are Registered")}
    %endif %elif allow_registration: - ${_("Register for")} ${course.display_number_with_default | h} + ${_("Register for")} ${course.display_number_with_default | h} %if len(course_modes) > 1: and choose your student track diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 7b077aec43..c76363bd59 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -13,6 +13,7 @@ <%namespace name='static' file='static_content.html'/> <%block name="title">${_("Dashboard")} +<%block name="bodyclass">view-dashboard is-authenticated <%block name="js_extra"> + + + +<%block name="content"> +
    + + + +
    +

    Your Progress

    +
      +
    1. Current: Step 1 Take Your Photo
    2. +
    3. Step 2 ID Photo
    4. +
    5. Step 3 Review
    6. +
    7. Step 4 Payment
    8. +
    9. Finished Confirmation
    10. +
    +
    + + + +
    + + + +
    +

    More questions? Check out our FAQs.

    +

    Change your mind? You can always Audit the course for free without verifying.

    +
    + + +
    + + + + + + + diff --git a/lms/templates/verify_student/final_verification.html b/lms/templates/verify_student/final_verification.html new file mode 100644 index 0000000000..c9abf876ae --- /dev/null +++ b/lms/templates/verify_student/final_verification.html @@ -0,0 +1,10 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="content"> + +Final Verification! + + + diff --git a/lms/templates/verify_student/photo_id_upload.html b/lms/templates/verify_student/photo_id_upload.html new file mode 100644 index 0000000000..1c8ec47dd7 --- /dev/null +++ b/lms/templates/verify_student/photo_id_upload.html @@ -0,0 +1,145 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="bodyclass">register verification select-track + +<%block name="js_extra"> + + + +<%block name="content"> +
    +
    + + + +

    Select your track:

    + +
    +
    +
    +

    Audit This Course

    +

    Sign up to audit this course for free and track your own progress.

    +
    + +
    +

    + Select Audit +

    +
    +
    + +

    or

    + +
    +
    +

    Certificate of Achievement

    + +

    Sign up as a verified student and work toward a Certificate of Achievement.

    + +
    +
    +
    + Select your contribution for this course: +
    +
    +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +
    + +

    + Why do I have to pay? What if I don't meet all the requirements? +

    + +
    +
    +
    Why do I have to pay?
    +
    Your payment helps cover the costs of verification. As a non-profit, edX keeps these costs as low as possible, Your payment will also help edX with our mission to provide quality education to anyone.
    +
    What if I can't afford it?
    +
    If you cannot afford the minimum payment, you can always work towards a free Honor Code Certificate of Achievement for this course. + + +
    + +
    I'd like to pay more than the minimum. Is my contribution tax deductible?
    +
    Please check with your tax advisor to determine whether your contribution is tax deductible.
    + +
    What if I don't meet all of the requirements for financial assistance but I still want to work toward a certificate?
    +
    If you don't have a webcam, credit or debit card or acceptable ID, you can opt to simply audit this course, or select to work towards a free Honor Code Certificate of Achievement for this course by checking the box below. Then click the Select Certificate button to complete registration. We won't ask you to verify your identity. +

    +
    +
    +
    + + + +
    +
    + +

    + What is an ID Verified Certificate? +

    + +

    + Select Certificate +

    +
    + +
    +

    + To register for a Verified Certificate of Achievement option, you will need a webcam, a credit or debit card, and an ID. View requirements +

    +
    + +
    + + +

    Have questions? Check out our FAQs.

    +

    Not the course you wanted? Return to our course listings.

    + + +
    +
    + + diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html new file mode 100644 index 0000000000..71373e93ad --- /dev/null +++ b/lms/templates/verify_student/photo_verification.html @@ -0,0 +1,357 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process step-photos +<%block name="title">${_("Register for {} | Verification").format(course_name)} + +<%block name="js_extra"> + + + + + +<%block name="content"> +
    +
    + + <%include file="_verification_header.html" args="course_name=course_name" /> + +
    +
    +

    ${_("Your Progress")}

    + + +
      +
    1. + 0 + ${_("Intro")} +
    2. + +
    3. + 1 + ${_("Current Step: ")}${_("Take Photo")} +
    4. + +
    5. + 2 + ${_("Take ID Photo")} +
    6. + +
    7. + 3 + ${_("Review")} +
    8. + +
    9. + 4 + ${_("Make Payment")} +
    10. + +
    11. + + + + ${_("Confirmation")} +
    12. +
    + + + + +
    +
    + +
    +
    + + +
    +
    + + <%include file="_verification_support.html" /> +
    +
    + +<%include file="_modal_editname.html" /> + diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html new file mode 100644 index 0000000000..4f21ce3020 --- /dev/null +++ b/lms/templates/verify_student/show_requirements.html @@ -0,0 +1,156 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%block name="bodyclass">register verification-process step-requirements +<%block name="title">${_("Register for {}").format(course_name)} + +<%block name="content"> +%if is_not_active: +
    +
    + +
    +

    ${_("You need to activate your edX account before proceeding")}

    +
    +

    ${_("Please check your email for further instructions on activating your new account.")}

    +
    +
    +
    +
    +%endif + +
    +
    + + <%include file="_verification_header.html" args="course_name=course_name"/> + +
    +
    +

    ${_("Your Progress")}

    + +
      +
    1. + 0 + ${_("Current Step: ")}${_("Intro")} +
    2. + +
    3. + 1 + ${_("Take Photo")} +
    4. + +
    5. + 2 + ${_("Take ID Photo")} +
    6. + +
    7. + 3 + ${_("Review")} +
    8. + +
    9. + 4 + ${_("Make Payment")} +
    10. + +
    11. + + + + ${_("Confirmation")} +
    12. +
    + + + + +
    +
    + + +
    +
    +

    ${_("What You Will Need to Register")}

    + +
    +

    ${_("There are three things you will need to register as an ID verified student:")}

    +
    + +
      + %if is_not_active: +
    • +

      ${_("Activate Your Account")}

      +
      + +
      + +
      +

      + ${_("Check Your Email")} + ${_("you need an active edX account before registering - check your email for instructions")} +

      +
      +
    • + %endif + +
    • +

      ${_("Identification")}

      +
      + + +
      + +
      +

      + ${_("A photo identification document")} + ${_("a drivers license, passport, or other goverment-issued ID with your name and picture on it")} +

      +
      +
    • + +
    • +

      ${_("Webcam")}

      +
      + +
      + +
      +

      + ${_("A webcam and a modern browser")} + ${_("Firefox, Chrome, Safari, IE9+")} +

      +
      +
    • + +
    • +

      ${_("Credit or Debit Card")}

      +
      + +
      + +
      +

      + ${_("A major credit or debit card")} + ${_("Visa, Master Card, American Express, Discover, Diners Club, JCB with Discover logo")} +

      +
      +
    • +
    + + +
    +
    + + <%include file="_verification_support.html" /> +
    +
    + diff --git a/lms/templates/verify_student/verified.html b/lms/templates/verify_student/verified.html new file mode 100644 index 0000000000..41b4b312b9 --- /dev/null +++ b/lms/templates/verify_student/verified.html @@ -0,0 +1,106 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process is-verified +<%block name="title">${_("Register for {} | Verification").format(course_name)} + +<%block name="js_extra"> + + + +<%block name="content"> +
    +
    + + <%include file="_verification_header.html" /> + +
    +
    +

    ${_("Your Progress")}

    + +
      +
    1. + 1 + ${_("ID Verification")} +
    2. +
    3. + 2 + ${_("Current Step: ")}${_("Review")} +
    4. + +
    5. + 3 + ${_("Make Payment")} +
    6. + +
    7. + + + + ${_("Confirmation")} +
    8. +
    + + + + +
    +
    + +
    +
    +

    ${_("You've Been Verified Previously")}

    + +
    +

    ${_("We've already verified your identity (through the photos of you and your ID you provided earlier). You can proceed to make your secure payment and complete registration.")}

    +
    + + +
    +
    + + <%include file="_verification_support.html" /> +
    +
    + diff --git a/lms/urls.py b/lms/urls.py index d0a04f7753..ddb30f1155 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -58,8 +58,16 @@ urlpatterns = ('', # nopep8 url(r'^heartbeat$', include('heartbeat.urls')), url(r'^user_api/', include('user_api.urls')), + ) +# if settings.MITX_FEATURES.get("MULTIPLE_ENROLLMENT_ROLES"): +urlpatterns += ( + url(r'^verify_student/', include('verify_student.urls')), + url(r'^course_modes/', include('course_modes.urls')), +) + + js_info_dict = { 'domain': 'djangojs', 'packages': ('lms',), @@ -338,6 +346,7 @@ if settings.COURSEWARE_ENABLED: name='submission_history'), ) + if settings.COURSEWARE_ENABLED and settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'): urlpatterns += ( url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard$', diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 388ad58676..77fbe40ecc 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -18,6 +18,7 @@ django-followit==0.0.3 django-keyedcache==1.4-6 django-kombu==0.9.4 django-mako==0.1.5pre +django-model-utils==1.4.0 django-masquerade==0.1.6 django-mptt==0.5.5 django-openid-auth==0.4 @@ -60,7 +61,6 @@ South==0.7.6 sympy==0.7.1 xmltodict==0.4.1 django-ratelimit-backend==0.6 -django-model-utils==1.4.0 # Used for debugging ipython==0.13.1