From d84f7648403b61c2c0b97ecf3cf2b26c70a1a63d Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 9 Aug 2013 12:14:29 -0400 Subject: [PATCH 001/185] Basic first commit of Photo ID Verification model and test code --- lms/djangoapps/verify_student/__init__.py | 0 lms/djangoapps/verify_student/api.py | 0 .../verify_student/migrations/__init__.py | 0 lms/djangoapps/verify_student/models.py | 322 ++++++++++++++++++ .../verify_student/tests/__init__.py | 0 .../verify_student/tests/test_models.py | 59 ++++ .../verify_student/tests/test_views.py | 37 ++ lms/djangoapps/verify_student/urls.py | 0 lms/djangoapps/verify_student/views.py | 13 + lms/envs/common.py | 3 + requirements/edx/base.txt | 1 + 11 files changed, 435 insertions(+) create mode 100644 lms/djangoapps/verify_student/__init__.py create mode 100644 lms/djangoapps/verify_student/api.py create mode 100644 lms/djangoapps/verify_student/migrations/__init__.py create mode 100644 lms/djangoapps/verify_student/models.py create mode 100644 lms/djangoapps/verify_student/tests/__init__.py create mode 100644 lms/djangoapps/verify_student/tests/test_models.py create mode 100644 lms/djangoapps/verify_student/tests/test_views.py create mode 100644 lms/djangoapps/verify_student/urls.py create mode 100644 lms/djangoapps/verify_student/views.py 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/__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..852bc4a50f --- /dev/null +++ b/lms/djangoapps/verify_student/models.py @@ -0,0 +1,322 @@ +# -*- coding: utf-8 -*- +""" +Models for Student Identity Verification + +Currently the only model is `PhotoVerificationAttempt`, but this is where we +would put any models relating to establishing the real-life identity of a +student over a period of time. +""" +from datetime import datetime +import functools +import logging +import uuid + +import pytz +from django.db import models +from django.contrib.auth.models import User + +from model_utils.models import StatusModel +from model_utils import Choices + +log = logging.getLogger(__name__) + + +class VerificationException(Exception): + pass + + +class IdVerifiedCourses(models.Model): + """ + A table holding all the courses that are eligible for ID Verification. + """ + course_id = models.CharField(blank=False, max_length=100) + + +def status_before_must_be(*valid_start_statuses): + """ + 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 PhotoVerificationAttempt(StatusModel): + """ + Each PhotoVerificationAttempt 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 create and edit a + `PhotoVerificationAttempt` object through the methods provided**. Do not + just construct one and start setting fields unless you really know what + you're doing. + + 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 == PhotoVerificationAttempt.STATUS.created + attempt.status == "created" + pending_requests = PhotoVerificationAttempt.submitted.all() + """ + ######################## 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) + + + ##### Methods listed in the order you'd typically call them + @classmethod + def user_is_verified(cls, user_id): + """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.""" + raise NotImplementedError + + + @classmethod + def active_for_user(cls, user_id): + """Return all PhotoVerificationAttempts 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? + """ + raise NotImplementedError + + + @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() + + 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..2c80447d6c --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -0,0 +1,59 @@ +# -*- 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 PhotoVerificationAttempt, VerificationException + + +class TestPhotoVerificationAttempt(object): + + 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 = PhotoVerificationAttempt(user=user) + assert_equals(attempt.status, PhotoVerificationAttempt.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_views.py b/lms/djangoapps/verify_student/tests/test_views.py new file mode 100644 index 0000000000..47b08f7b35 --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -0,0 +1,37 @@ +""" + + +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/start?course_id={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())) + + \ No newline at end of file diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py new file mode 100644 index 0000000000..964f8fa0f3 --- /dev/null +++ b/lms/djangoapps/verify_student/views.py @@ -0,0 +1,13 @@ +""" + + +""" + +@login_required +def start(request): + """ + If they've already started a PhotoVerificationAttempt, we move to wherever + they are in that process. If they've completed one, then we skip straight + to payment. + """ + \ No newline at end of file diff --git a/lms/envs/common.py b/lms/envs/common.py index 3e65c70805..cbcf2d59e7 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -774,6 +774,9 @@ INSTALLED_APPS = ( # Different Course Modes 'course_modes' + + # Student Identity Verification + 'verify_student', ) ######################### MARKETING SITE ############################### diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9179315797..070f0a060d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -12,6 +12,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 From 59c2bb18ef0ae3c7b7f806cb6d1be52b6e4ed381 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 15 Aug 2013 14:20:36 -0400 Subject: [PATCH 002/185] Bare bones outline of ID verification templates --- lms/djangoapps/verify_student/urls.py | 28 +++++++++++++++++++ lms/djangoapps/verify_student/views.py | 24 ++++++++++++++-- lms/envs/common.py | 5 +++- lms/envs/dev.py | 1 + lms/templates/courseware/course_about.html | 2 ++ lms/templates/verify_student/face_upload.html | 11 ++++++++ .../verify_student/final_verification.html | 10 +++++++ .../verify_student/photo_id_upload.html | 11 ++++++++ .../verify_student/show_requirements.html | 12 ++++++++ lms/urls.py | 6 ++++ 10 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 lms/templates/verify_student/face_upload.html create mode 100644 lms/templates/verify_student/final_verification.html create mode 100644 lms/templates/verify_student/photo_id_upload.html create mode 100644 lms/templates/verify_student/show_requirements.html diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index e69de29bb2..a3644615e8 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -0,0 +1,28 @@ +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', + views.show_requirements, + name="verify_student/show_requirements" + ), + url( + r'^face_upload', + views.face_upload, + name="verify_student/face_upload" + ), + url( + r'^photo_id_upload', + views.photo_id_upload, + name="verify_student/photo_id_upload" + ), + url( + r'^final_verification', + views.final_verification, + name="verify_student/final_verification" + ), +) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 964f8fa0f3..acaafb092d 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -2,12 +2,30 @@ """ +from mitxmako.shortcuts import render_to_response -@login_required -def start(request): +# @login_required +def start_or_resume_attempt(request): """ If they've already started a PhotoVerificationAttempt, we move to wherever they are in that process. If they've completed one, then we skip straight to payment. """ - \ No newline at end of file + pass + +def show_requirements(request): + """This might just be a plain template without a view.""" + context = { "course_id" : "edX/Certs101/2013_Test" } + return render_to_response("verify_student/show_requirements.html", context) + +def face_upload(request): + context = { "course_id" : "edX/Certs101/2013_Test" } + return render_to_response("verify_student/face_upload.html", context) + +def photo_id_upload(request): + context = { "course_id" : "edX/Certs101/2013_Test" } + return render_to_response("verify_student/photo_id_upload.html", context) + +def final_verification(request): + context = { "course_id" : "edX/Certs101/2013_Test" } + return render_to_response("verify_student/final_verification.html", context) diff --git a/lms/envs/common.py b/lms/envs/common.py index cbcf2d59e7..930fd6dcab 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -154,6 +154,9 @@ MITX_FEATURES = { # Toggle to enable chat availability (configured on a per-course # basis in Studio) 'ENABLE_CHAT': False, + + # Allow users to enroll with methods other than just honor code certificates + 'MULTIPLE_ENROLLMENT_ROLES' : False } # Used for A/B testing @@ -773,7 +776,7 @@ INSTALLED_APPS = ( 'notification_prefs', # Different Course Modes - 'course_modes' + 'course_modes', # Student Identity Verification 'verify_student', diff --git a/lms/envs/dev.py b/lms/envs/dev.py index b9768554b1..889ba71e35 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -30,6 +30,7 @@ MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True FEEDBACK_SUBMISSION_EMAIL = "dummy@dummy.org" diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index e4a453133d..39cf04e72c 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -96,6 +96,8 @@ %else: ${_("Register for {course.display_number_with_default}").format(course=course) | h} + Mock Verify Enrollment +
%endif diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html new file mode 100644 index 0000000000..6338750c06 --- /dev/null +++ b/lms/templates/verify_student/face_upload.html @@ -0,0 +1,11 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="content"> + +

Face Upload!

+ +Upload Photo ID + + 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..b6724656f4 --- /dev/null +++ b/lms/templates/verify_student/photo_id_upload.html @@ -0,0 +1,11 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="content"> + +

Photo ID Upload!

+ +Final Verification + + diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html new file mode 100644 index 0000000000..5fa00a0145 --- /dev/null +++ b/lms/templates/verify_student/show_requirements.html @@ -0,0 +1,12 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="content"> + +

Requirements Page!

+ +Upload Face + + + diff --git a/lms/urls.py b/lms/urls.py index b32c0263d0..58c2cd3b55 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -61,8 +61,13 @@ 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')),) + + js_info_dict = { 'domain': 'djangojs', 'packages': ('lms',), @@ -340,6 +345,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$', From 94c442c9ccaef9b11e99b4858c02810a049d21ce Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Mon, 19 Aug 2013 16:48:36 -0400 Subject: [PATCH 003/185] initial rough skeleton for verification flow --- lms/templates/verify_student/face_upload.html | 244 +++++++++++++++++- 1 file changed, 242 insertions(+), 2 deletions(-) diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html index 6338750c06..662934c20d 100644 --- a/lms/templates/verify_student/face_upload.html +++ b/lms/templates/verify_student/face_upload.html @@ -3,9 +3,249 @@ <%inherit file="../main.html" /> <%block name="content"> +
-

Face Upload!

+ -Upload Photo ID +
+

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. +
+
+ + + +
+
+

Take Your Photo

+

Use your webcam to take a picture of your face so we can match it with the picture on your ID.

+ +
+ +
+ +
+ +
+ + + + + +
+ +
+ +
+

Tips on taking a successful photo

+
    +
  • Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.
  • +
  • Maecenas faucibus mollis interdum.
  • +
  • Nullam id dolor id nibh ultricies vehicula ut id elit.
  • +
  • Cras mattis consectetur purus sit amet fermentum.
  • +
+
+ +
+

Common Questions

+
+
Cras justo odio, dapibus ac facilisis in, egestas eget quam.
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
Vestibulum id ligula porta felis euismod semper.
+
Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
+
Aenean eu leo quam.
+
Pellentesque ornare sem lacinia quam venenatis vestibulum.
+
Maecenas faucibus mollis interdum.
+
+
+ +
+ +
+
+ + + + +
+

Take Your Photo

+

Use your webcam to take a picture of your face so we can match it with the picture on your ID.

+ +
+ +
+ +
+ + + +
+ +
+

Tips on taking a successful photo

+
    +
  • Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.
  • +
  • Maecenas faucibus mollis interdum.
  • +
  • Nullam id dolor id nibh ultricies vehicula ut id elit.
  • +
  • Cras mattis consectetur purus sit amet fermentum.
  • +
+
+ +
+

Common Questions

+
+
Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
+
Aenean eu leo quam.
+
Pellentesque ornare sem lacinia quam venenatis vestibulum.
+
Maecenas faucibus mollis interdum.
+
Cras justo odio, dapibus ac facilisis in, egestas eget quam.
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
Vestibulum id ligula porta felis euismod semper.
+
+
+ +
+ +
+
+ + + +
+

Verify Your Submission

+

Make sure we can verify your identity with the photos and information below.

+ +
+

Check Your Name

+

Make sure your full name on your edX account, [User Name], matches your ID. We will also use this as the name on your certificate.

+

Edit my name

+ + +
+ +
+
+ +
+ +

The photo above needs to meet the following requirements:

+
    +
  • Be well lit
  • +
  • Show your whole face
  • +
  • Match your ID
  • +
+
+ +
+
+ +
+ +

The photo above needs to meet the following requirements:

+
    +
  • Be readable (not too far away, no glare)
  • +
  • Show your name
  • +
  • Match the photo of your face and your name above
  • +
+
+ + + + + +
+ + + +
+

More questions? Check out our FAQs.

+

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

+
+ + + + +
From 2e821be65fa5f00d237ef6eb36bc5061fccee533 Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Tue, 20 Aug 2013 16:04:48 -0400 Subject: [PATCH 004/185] adding some standard sass bits to LMS and bare bones sass for verification --- lms/static/sass/base/_mixins.scss | 6 + lms/static/sass/elements/_typography.scss | 178 ++++++++++++++++++ lms/static/sass/views/_verification.scss | 28 +++ lms/templates/verify_student/face_upload.html | 84 +++++---- 4 files changed, 259 insertions(+), 37 deletions(-) create mode 100644 lms/static/sass/elements/_typography.scss create mode 100644 lms/static/sass/views/_verification.scss diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index e2074f1976..8ee4559e36 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -1,3 +1,6 @@ +// lms - utilities - mixins and extends +// ==================== + // mixins - font sizing @mixin font-size($sizeValue: 16){ font-size: $sizeValue + px; @@ -44,6 +47,9 @@ } + + + //----------------- // Theme Mixin Styles //----------------- diff --git a/lms/static/sass/elements/_typography.scss b/lms/static/sass/elements/_typography.scss new file mode 100644 index 0000000000..157d3d60df --- /dev/null +++ b/lms/static/sass/elements/_typography.scss @@ -0,0 +1,178 @@ +// 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); + @include line-height(48); +} + +.t-icon2 { + @include font-size(36); + @include line-height(36); +} + +.t-icon3 { + @include font-size(24); + @include line-height(24); +} + +.t-icon4 { + @include font-size(18); + @include line-height(18); +} + +.t-icon5 { + @include font-size(16); + @include line-height(16); +} + +.t-icon6 { + @include font-size(14); + @include line-height(14); +} + +.t-icon7 { + @include font-size(12); + @include line-height(12); +} + +.t-icon8 { + @include font-size(11); + @include line-height(11); +} + +.t-icon9 { + @include font-size(10); + @include line-height(10); +} diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss new file mode 100644 index 0000000000..e65901ab50 --- /dev/null +++ b/lms/static/sass/views/_verification.scss @@ -0,0 +1,28 @@ +// lms - views - verification flow +// ==================== + +body.register.verification { + + .page-header { + @extend .t-title3; + + + + .title { + + } + } + + .progress { + + } + + .block-photo { + + + } + + + + +} diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html index 662934c20d..5f2c7959bc 100644 --- a/lms/templates/verify_student/face_upload.html +++ b/lms/templates/verify_student/face_upload.html @@ -1,6 +1,14 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> <%inherit file="../main.html" /> +<%block name="bodyclass">register verification + +<%block name="js_extra"> + + + <%block name="content">
@@ -12,19 +20,19 @@

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. +
  11. Current: Step 1 Take Your Photo
  12. +
  13. Step 2 ID Photo
  14. +
  15. Step 3 Review
  16. +
  17. Step 4 Payment
  18. +
  19. Finished Confirmation
-
-

Take Your Photo

+
+

Take Your Photo

Use your webcam to take a picture of your face so we can match it with the picture on your ID.

@@ -97,8 +105,8 @@ -
-

Take Your Photo

+
+

Take Your Photo

Use your webcam to take a picture of your face so we can match it with the picture on your ID.

@@ -153,40 +161,14 @@ -
-

Verify Your Submission

+
+

Verify Your Submission

Make sure we can verify your identity with the photos and information below.

Check Your Name

Make sure your full name on your edX account, [User Name], matches your ID. We will also use this as the name on your certificate.

Edit my name

- -
@@ -248,4 +230,32 @@
+ + + + From 07b94f7dd4cc356e8c64d2e18392423bea24359e Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Tue, 20 Aug 2013 21:59:23 -0400 Subject: [PATCH 005/185] added font variables for LMS, more sass for verification --- lms/static/sass/base/_variables.scss | 13 +- lms/static/sass/views/_verification.scss | 101 ++++++- lms/templates/verify_student/face_upload.html | 264 ++++++++++-------- 3 files changed, 257 insertions(+), 121 deletions(-) diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 93297f4043..2028b95efb 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -9,6 +9,7 @@ $fg-max-columns: 12; $fg-max-width: 1400px; $fg-min-width: 810px; +// fonts $sans-serif: 'Open Sans', $verdana; $monospace: Monaco, 'Bitstream Vera Sans Mono', 'Lucida Console', monospace; $body-font-family: $sans-serif; @@ -197,4 +198,14 @@ $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'; \ No newline at end of file +$video-thumb-url: '../images/courses/video-thumb.jpg'; + +//----------------- +// Newer variables ported from Studio +//----------------- + +// 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/views/_verification.scss b/lms/static/sass/views/_verification.scss index e65901ab50..a0eebf0fea 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -2,27 +2,120 @@ // ==================== body.register.verification { + font-family: 'Open Sans', sans-serif; + + h1, h2, h3, h4, h5, h6, p { + font-family: 'Open Sans', sans-serif; + } + .page-header { - @extend .t-title3; - - .title { - + @extend .t-title5; } } .progress { + .progress-step { + border: 1px solid #eee; + display: inline-block; + padding: ($baseline/2) $baseline; + } + } + + + // for dev placement only + .placeholder-cam, + .placeholder-photo { + height: 300px; + background-color: #eee; + position: relative; + + p { + position: absolute; + top: 40%; + left: 40%; + color: #ccc; + } + } + + + #wrapper { + overflow: hidden; } .block-photo { + @include clearfix(); + + .title { + font-weight: bold; + } + + .wrapper-up, + .wrapper-down { + @include clearfix(); + } + + .cam { + width: 45%; + float: left; + padding-right: $baseline; + } + + .photo-controls { + background-color: #ddd; + + .controls-list { + margin: 0; + padding: 0; + list-style-type: none; + + .control { + display: inline-block; + } + } + } + + .faq { + width: 45%; + float: left; + padding-right: $baseline; + + dt { + font-weight: bold; + padding: 0 0 ($baseline/2) 0; + } + + dd { + margin: 0; + padding: 0 0 $baseline 0; + } + } + } + .photo-tips { + width: 45%; + float: left; + } + .actions { + width: 45%; + float: right; + } + + .review-photo { + width: 45%; + float: left; + } + + #review-facephoto { + margin-right: $baseline; + } } diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html index 5f2c7959bc..2010ec53fe 100644 --- a/lms/templates/verify_student/face_upload.html +++ b/lms/templates/verify_student/face_upload.html @@ -20,7 +20,7 @@

Your Progress

    -
  1. Current: Step 1 Take Your Photo
  2. +
  3. Current: Step 1 Take Your Photo
  4. Step 2 ID Photo
  5. Step 3 Review
  6. Step 4 Payment
  7. @@ -31,137 +31,167 @@
    -
    +

    Take Your Photo

    Use your webcam to take a picture of your face so we can match it with the picture on your ID.

    -
    +
    -
    - +
    +
    + +

    cam image

    +
    + +
    + + + + + +
    -
    +
    +

    Tips on taking a successful photo

      -
    • - Take photo -
    • +
    • Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.
    • +
    • Maecenas faucibus mollis interdum.
    • +
    • Nullam id dolor id nibh ultricies vehicula ut id elit.
    • +
    • Cras mattis consectetur purus sit amet fermentum.
    +
    +
    - +
    +
    +

    Common Questions

    +
    +
    Cras justo odio, dapibus ac facilisis in, egestas eget quam.
    +
    Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    +
    Vestibulum id ligula porta felis euismod semper.
    +
    Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
    +
    Aenean eu leo quam.
    +
    Pellentesque ornare sem lacinia quam venenatis vestibulum.
    +
    Maecenas faucibus mollis interdum.
    +
    +
    +
    - -
    - -
    -

    Tips on taking a successful photo

    -
      -
    • Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.
    • -
    • Maecenas faucibus mollis interdum.
    • -
    • Nullam id dolor id nibh ultricies vehicula ut id elit.
    • -
    • Cras mattis consectetur purus sit amet fermentum.
    • -
    -
    - -
    -

    Common Questions

    -
    -
    Cras justo odio, dapibus ac facilisis in, egestas eget quam.
    -
    Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    -
    Vestibulum id ligula porta felis euismod semper.
    -
    Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
    -
    Aenean eu leo quam.
    -
    Pellentesque ornare sem lacinia quam venenatis vestibulum.
    -
    Maecenas faucibus mollis interdum.
    -
    -
    - -
    -
    -
    +

    Take Your Photo

    Use your webcam to take a picture of your face so we can match it with the picture on your ID.

    -
    +
    + +
    + +
    + +

    cam image

    +
    + +
    + + + + + +
    -
    -
    -
    - Take photo - - Retake - Looks good - - Looks good +
    +

    Tips on taking a successful photo

    +
      +
    • Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.
    • +
    • Maecenas faucibus mollis interdum.
    • +
    • Nullam id dolor id nibh ultricies vehicula ut id elit.
    • +
    • Cras mattis consectetur purus sit amet fermentum.
    • +
    -
    -

    Tips on taking a successful photo

    -
      -
    • Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.
    • -
    • Maecenas faucibus mollis interdum.
    • -
    • Nullam id dolor id nibh ultricies vehicula ut id elit.
    • -
    • Cras mattis consectetur purus sit amet fermentum.
    • -
    +
    + +
    +

    Common Questions

    +
    +
    Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
    +
    Aenean eu leo quam.
    +
    Pellentesque ornare sem lacinia quam venenatis vestibulum.
    +
    Maecenas faucibus mollis interdum.
    +
    Cras justo odio, dapibus ac facilisis in, egestas eget quam.
    +
    Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    +
    Vestibulum id ligula porta felis euismod semper.
    +
    +
    + +
    + +
    -
    -

    Common Questions

    -
    -
    Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
    -
    Aenean eu leo quam.
    -
    Pellentesque ornare sem lacinia quam venenatis vestibulum.
    -
    Maecenas faucibus mollis interdum.
    -
    Cras justo odio, dapibus ac facilisis in, egestas eget quam.
    -
    Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    -
    Vestibulum id ligula porta felis euismod semper.
    -
    -
    - -
    - -
    -
    +

    Verify Your Submission

    Make sure we can verify your identity with the photos and information below.

    @@ -171,30 +201,32 @@

    Edit my name

    -
    -
    - +
    +
    +
    + +
    + +

    The photo above needs to meet the following requirements:

    +
      +
    • Be well lit
    • +
    • Show your whole face
    • +
    • Match your ID
    • +
    -

    The photo above needs to meet the following requirements:

    -
      -
    • Be well lit
    • -
    • Show your whole face
    • -
    • Match your ID
    • -
    -
    +
    +
    + +
    -
    -
    - +

    The photo above needs to meet the following requirements:

    +
      +
    • Be readable (not too far away, no glare)
    • +
    • Show your name
    • +
    • Match the photo of your face and your name above
    • +
    - -

    The photo above needs to meet the following requirements:

    -
      -
    • Be readable (not too far away, no glare)
    • -
    • Show your name
    • -
    • Match the photo of your face and your name above
    • -
    From b7d73933b7c2379eb9ce3805e6eadbcb6a9cfa9a Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 7 Aug 2013 15:48:40 -0700 Subject: [PATCH 006/185] initial commit of shopping cart and cybersource integration --- common/djangoapps/student/views.py | 2 +- lms/urls.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4d59b5cc66..92f9d7f814 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -386,7 +386,7 @@ def change_enrollment(request): CourseEnrollment.unenroll(user, course_id) org, course_num, run = course_id.split("/") - statsd.increment("common.student.unenrollment", + log.increment("common.student.unenrollment", tags=["org:{0}".format(org), "course:{0}".format(course_num), "run:{0}".format(run)]) diff --git a/lms/urls.py b/lms/urls.py index b32c0263d0..9034683556 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -377,6 +377,11 @@ if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): ) +# Shopping cart +urlpatterns += ( + url(r'^shoppingcarttest/(?P[^/]+/[^/]+/[^/]+)/$','shoppingcart.views.test'), +) + if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): urlpatterns += ( From 9e56028091fc9f4819ef7c3072c9bf5d1b05e467 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 7 Aug 2013 22:53:36 -0700 Subject: [PATCH 007/185] added shopping cart list template, embedded form --- lms/djangoapps/shoppingcart/__init__.py | 0 .../shoppingcart/inventory_types.py | 68 ++++++++++++++ lms/djangoapps/shoppingcart/models.py | 3 + lms/djangoapps/shoppingcart/tests.py | 16 ++++ lms/djangoapps/shoppingcart/urls.py | 9 ++ lms/djangoapps/shoppingcart/views.py | 91 +++++++++++++++++++ lms/templates/shoppingcart/list.html | 46 ++++++++++ lms/urls.py | 2 +- 8 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/shoppingcart/__init__.py create mode 100644 lms/djangoapps/shoppingcart/inventory_types.py create mode 100644 lms/djangoapps/shoppingcart/models.py create mode 100644 lms/djangoapps/shoppingcart/tests.py create mode 100644 lms/djangoapps/shoppingcart/urls.py create mode 100644 lms/djangoapps/shoppingcart/views.py create mode 100644 lms/templates/shoppingcart/list.html diff --git a/lms/djangoapps/shoppingcart/__init__.py b/lms/djangoapps/shoppingcart/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/inventory_types.py b/lms/djangoapps/shoppingcart/inventory_types.py new file mode 100644 index 0000000000..0230760cb5 --- /dev/null +++ b/lms/djangoapps/shoppingcart/inventory_types.py @@ -0,0 +1,68 @@ +import logging +from django.contrib.auth.models import User +from student.views import course_from_id +from student.models import CourseEnrollmentAllowed, CourseEnrollment +from statsd import statsd + +log = logging.getLogger("shoppingcart") + +class InventoryItem(object): + """ + This is the abstract interface for inventory items. + Inventory items are things that fill up the shopping cart. + + Each implementation of InventoryItem should have purchased_callback as + a method and data attributes as defined in __init__ below + """ + def __init__(self): + # Set up default data attribute values + self.qty = 1 + self.unit_cost = 0 # in dollars + self.line_cost = 0 # qty * unit_cost + self.line_desc = "Misc Item" + + def purchased_callback(self, user_id): + """ + This is called on each inventory item in the shopping cart when the + purchase goes through. The parameter provided is the id of the user who + made the purchase. + """ + raise NotImplementedError + + +class PaidCourseRegistration(InventoryItem): + """ + This is an inventory item for paying for a course registration + """ + def __init__(self, course_id, unit_cost): + course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + self.qty = 1 + self.unit_cost = unit_cost + self.line_cost = unit_cost + self.course_id = course_id + self.line_desc = "Registration for Course {0}".format(course_id) + + def purchased_callback(self, user_id): + """ + When purchased, this should enroll the user in the course. We are assuming that + course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found in + CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment + would in fact be quite silly since there's a clear back door. + """ + user = User.objects.get(id=user_id) + course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for + # whatever reason. + # Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency + # with rest of codebase. + CourseEnrollmentAllowed.objects.get_or_create(email=user.email, course_id=self.course_id, auto_enroll=True) + CourseEnrollment.objects.get_or_create(user=user, course_id=self.course_id) + + log.info("Enrolled {0} in paid course {1}, paid ${2}".format(user.email, self.course_id, self.line_cost)) + org, course_num, run = self.course_id.split("/") + statsd.increment("shoppingcart.PaidCourseRegistration.purchased_callback.enrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py new file mode 100644 index 0000000000..71a8362390 --- /dev/null +++ b/lms/djangoapps/shoppingcart/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py new file mode 100644 index 0000000000..501deb776c --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests.py @@ -0,0 +1,16 @@ +""" +This file demonstrates writing tests using the unittest module. These will pass +when you run "manage.py test". + +Replace this with more appropriate tests for your application. +""" + +from django.test import TestCase + + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.assertEqual(1 + 1, 2) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py new file mode 100644 index 0000000000..47bd3c4c3d --- /dev/null +++ b/lms/djangoapps/shoppingcart/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import patterns, include, url + +urlpatterns = patterns('shoppingcart.views', # nopep8 + url(r'^$','show_cart'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/$','test'), + url(r'^add/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), + url(r'^clear/$','clear_cart'), + url(r'^remove_item/$', 'remove_item'), +) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py new file mode 100644 index 0000000000..e7d09e18b7 --- /dev/null +++ b/lms/djangoapps/shoppingcart/views.py @@ -0,0 +1,91 @@ +import logging +import random +import time +import hmac +import binascii +from hashlib import sha1 + +from collections import OrderedDict +from django.http import HttpResponse +from django.contrib.auth.decorators import login_required +from mitxmako.shortcuts import render_to_response +from .inventory_types import * + +log = logging.getLogger("shoppingcart") + + +def test(request, course_id): + item1 = PaidCourseRegistration(course_id, 200) + item1.purchased_callback(request.user.id) + return HttpResponse('OK') + +@login_required +def add_course_to_cart(request, course_id): + cart = request.session.get('shopping_cart', []) + course_ids_in_cart = [i.course_id for i in cart if isinstance(i, PaidCourseRegistration)] + if course_id not in course_ids_in_cart: + # TODO: Catch 500 here for course that does not exist, period + item = PaidCourseRegistration(course_id, 200) + cart.append(item) + request.session['shopping_cart'] = cart + return HttpResponse('Added') + else: + return HttpResponse("Item exists, not adding") + +@login_required +def show_cart(request): + cart = request.session.get('shopping_cart', []) + total_cost = "{0:0.2f}".format(sum([i.line_cost for i in cart])) + params = OrderedDict() + params['amount'] = total_cost + params['currency'] = 'usd' + params['orderPage_transactionType'] = 'sale' + params['orderNumber'] = "{0:d}".format(random.randint(1, 10000)) + signed_param_dict = cybersource_sign(params) + return render_to_response("shoppingcart/list.html", + {'shoppingcart_items': cart, + 'total_cost': total_cost, + 'params': signed_param_dict, + }) + +@login_required +def clear_cart(request): + request.session['shopping_cart'] = [] + return HttpResponse('Cleared') + +@login_required +def remove_item(request): + # doing this with indexes to replicate the function that generated the list on the HTML page + item_idx = request.REQUEST.get('idx', 'blank') + try: + cart = request.session.get('shopping_cart', []) + cart.pop(int(item_idx)) + request.session['shopping_cart'] = cart + except IndexError, ValueError: + log.exception('Cannot remove element at index {0} from cart'.format(item_idx)) + return HttpResponse('OK') + + +def cybersource_sign(params): + """ + params needs to be an ordered dict, b/c cybersource documentation states that order is important. + Reverse engineered from PHP version provided by cybersource + """ + shared_secret = "ELIDED" + merchant_id = "ELIDED" + serial_number = "ELIDED" + orderPage_version = "7" + params['merchantID'] = merchant_id + params['orderPage_timestamp'] = int(time.time()*1000) + params['orderPage_version'] = orderPage_version + params['orderPage_serialNumber'] = serial_number + fields = ",".join(params.keys()) + values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) + fields_hash_obj = hmac.new(shared_secret, fields, sha1) + fields_sig = binascii.b2a_base64(fields_hash_obj.digest())[:-1] # last character is a '\n', which we don't want + values += ",signedFieldsPublicSignature=" + fields_sig + values_hash_obj = hmac.new(shared_secret, values, sha1) + params['orderPage_signaturePublic'] = binascii.b2a_base64(values_hash_obj.digest())[:-1] + params['orderPage_signedFields'] = fields + + return params \ No newline at end of file diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html new file mode 100644 index 0000000000..f3fd26c96b --- /dev/null +++ b/lms/templates/shoppingcart/list.html @@ -0,0 +1,46 @@ +<%! from django.utils.translation import ugettext as _ %> + +<%! from django.core.urlresolvers import reverse %> + +<%inherit file="../main.html" /> + +<%block name="title">${_("Your Shopping Cart")} + +
    + + + + + + % for idx,item in enumerate(shoppingcart_items): + + + % endfor + + + + +
    QtyDescriptionUnit PricePrice
    ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost}[x]
    Total Cost
    ${total_cost}
    + +
    + % for pk, pv in params.iteritems(): + + % endfor + +
    +
    + + + + diff --git a/lms/urls.py b/lms/urls.py index 9034683556..53665f9ef6 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -379,7 +379,7 @@ if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): # Shopping cart urlpatterns += ( - url(r'^shoppingcarttest/(?P[^/]+/[^/]+/[^/]+)/$','shoppingcart.views.test'), + url(r'^shoppingcart/', include('shoppingcart.urls')), ) From 1a3953ca892767f45000cdda55581dc3fdf6b2a0 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 7 Aug 2013 23:29:33 -0700 Subject: [PATCH 008/185] add parameterization of cybersource creds --- lms/djangoapps/shoppingcart/views.py | 9 +++--- lms/envs/aws.py | 2 ++ lms/envs/common.py | 8 ++++++ lms/templates/shoppingcart/list.html | 43 ++++++++++++++++------------ 4 files changed, 39 insertions(+), 23 deletions(-) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index e7d09e18b7..a2680fd845 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -5,6 +5,7 @@ import hmac import binascii from hashlib import sha1 +from django.conf import settings from collections import OrderedDict from django.http import HttpResponse from django.contrib.auth.decorators import login_required @@ -71,10 +72,10 @@ def cybersource_sign(params): params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ - shared_secret = "ELIDED" - merchant_id = "ELIDED" - serial_number = "ELIDED" - orderPage_version = "7" + shared_secret = settings.CYBERSOURCE.get('SHARED_SECRET','') + merchant_id = settings.CYBERSOURCE.get('MERCHANT_ID','') + serial_number = settings.CYBERSOURCE.get('SERIAL_NUMBER','') + orderPage_version = settings.CYBERSOURCE.get('ORDERPAGE_VERSION','7') params['merchantID'] = merchant_id params['orderPage_timestamp'] = int(time.time()*1000) params['orderPage_version'] = orderPage_version diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 8d2ffba96e..cc0e956b0c 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -191,6 +191,8 @@ if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False) +CYBERSOURCE = AUTH_TOKENS.get('CYBERSOURCE', CYBERSOURCE) + SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] diff --git a/lms/envs/common.py b/lms/envs/common.py index 250552a40c..d397371cc2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -431,6 +431,14 @@ ZENDESK_URL = None ZENDESK_USER = None ZENDESK_API_KEY = None +##### CyberSource Payment parameters ##### +CYBERSOURCE = { + 'SHARED_SECRET': '', + 'MERCHANT_ID' : '', + 'SERIAL_NUMBER' : '', + 'ORDERPAGE_VERSION': '7', +} + ################################# open ended grading config ##################### #By setting up the default settings with an incorrect user name and password, diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index f3fd26c96b..a1f785c8b4 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -7,27 +7,32 @@ <%block name="title">${_("Your Shopping Cart")}
    - - - - - - % for idx,item in enumerate(shoppingcart_items): - - - % endfor - - + % if shoppingcart_items: +
    QtyDescriptionUnit PricePrice
    ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost}[x]
    Total Cost
    ${total_cost}
    + + + + + % for idx,item in enumerate(shoppingcart_items): + + + % endfor + + - -
    QtyDescriptionUnit PricePrice
    ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost}[x]
    Total Cost
    ${total_cost}
    + + + +
    + % for pk, pv in params.iteritems(): + + % endfor + +
    + % else: +

    You have selected no items for purchase.

    + % endif -
    - % for pk, pv in params.iteritems(): - - % endfor - -
    From ed4e7f54c780ca1c993885800bb0f138b68070c4 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 8 Aug 2013 18:07:18 -0700 Subject: [PATCH 009/185] Move shopping cart from session into model/db --- common/djangoapps/student/views.py | 2 +- .../shoppingcart/inventory_types.py | 68 -------- .../shoppingcart/migrations/0001_initial.py | 116 +++++++++++++ .../shoppingcart/migrations/__init__.py | 0 lms/djangoapps/shoppingcart/models.py | 160 +++++++++++++++++- lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 60 ++++--- lms/envs/common.py | 3 + lms/templates/shoppingcart/list.html | 6 +- 9 files changed, 320 insertions(+), 98 deletions(-) delete mode 100644 lms/djangoapps/shoppingcart/inventory_types.py create mode 100644 lms/djangoapps/shoppingcart/migrations/0001_initial.py create mode 100644 lms/djangoapps/shoppingcart/migrations/__init__.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 92f9d7f814..4d59b5cc66 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -386,7 +386,7 @@ def change_enrollment(request): CourseEnrollment.unenroll(user, course_id) org, course_num, run = course_id.split("/") - log.increment("common.student.unenrollment", + statsd.increment("common.student.unenrollment", tags=["org:{0}".format(org), "course:{0}".format(course_num), "run:{0}".format(run)]) diff --git a/lms/djangoapps/shoppingcart/inventory_types.py b/lms/djangoapps/shoppingcart/inventory_types.py deleted file mode 100644 index 0230760cb5..0000000000 --- a/lms/djangoapps/shoppingcart/inventory_types.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -from django.contrib.auth.models import User -from student.views import course_from_id -from student.models import CourseEnrollmentAllowed, CourseEnrollment -from statsd import statsd - -log = logging.getLogger("shoppingcart") - -class InventoryItem(object): - """ - This is the abstract interface for inventory items. - Inventory items are things that fill up the shopping cart. - - Each implementation of InventoryItem should have purchased_callback as - a method and data attributes as defined in __init__ below - """ - def __init__(self): - # Set up default data attribute values - self.qty = 1 - self.unit_cost = 0 # in dollars - self.line_cost = 0 # qty * unit_cost - self.line_desc = "Misc Item" - - def purchased_callback(self, user_id): - """ - This is called on each inventory item in the shopping cart when the - purchase goes through. The parameter provided is the id of the user who - made the purchase. - """ - raise NotImplementedError - - -class PaidCourseRegistration(InventoryItem): - """ - This is an inventory item for paying for a course registration - """ - def __init__(self, course_id, unit_cost): - course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to - # throw errors if it doesn't - self.qty = 1 - self.unit_cost = unit_cost - self.line_cost = unit_cost - self.course_id = course_id - self.line_desc = "Registration for Course {0}".format(course_id) - - def purchased_callback(self, user_id): - """ - When purchased, this should enroll the user in the course. We are assuming that - course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found in - CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment - would in fact be quite silly since there's a clear back door. - """ - user = User.objects.get(id=user_id) - course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to - # throw errors if it doesn't - # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for - # whatever reason. - # Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency - # with rest of codebase. - CourseEnrollmentAllowed.objects.get_or_create(email=user.email, course_id=self.course_id, auto_enroll=True) - CourseEnrollment.objects.get_or_create(user=user, course_id=self.course_id) - - log.info("Enrolled {0} in paid course {1}, paid ${2}".format(user.email, self.course_id, self.line_cost)) - org, course_num, run = self.course_id.split("/") - statsd.increment("shoppingcart.PaidCourseRegistration.purchased_callback.enrollment", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run)]) diff --git a/lms/djangoapps/shoppingcart/migrations/0001_initial.py b/lms/djangoapps/shoppingcart/migrations/0001_initial.py new file mode 100644 index 0000000000..779eccc94d --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0001_initial.py @@ -0,0 +1,116 @@ +# -*- 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 'Order' + db.create_table('shoppingcart_order', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), + ('nonce', self.gf('django.db.models.fields.CharField')(max_length=128)), + ('purchase_time', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), + )) + db.send_create_signal('shoppingcart', ['Order']) + + # Adding model 'OrderItem' + db.create_table('shoppingcart_orderitem', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('order', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['shoppingcart.Order'])), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), + ('qty', self.gf('django.db.models.fields.IntegerField')(default=1)), + ('unit_cost', self.gf('django.db.models.fields.FloatField')(default=0.0)), + ('line_cost', self.gf('django.db.models.fields.FloatField')(default=0.0)), + ('line_desc', self.gf('django.db.models.fields.CharField')(default='Misc. Item', max_length=1024)), + )) + db.send_create_signal('shoppingcart', ['OrderItem']) + + # Adding model 'PaidCourseRegistration' + db.create_table('shoppingcart_paidcourseregistration', ( + ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + )) + db.send_create_signal('shoppingcart', ['PaidCourseRegistration']) + + + def backwards(self, orm): + # Deleting model 'Order' + db.delete_table('shoppingcart_order') + + # Deleting model 'OrderItem' + db.delete_table('shoppingcart_orderitem') + + # Deleting model 'PaidCourseRegistration' + db.delete_table('shoppingcart_paidcourseregistration') + + + 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'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'nonce': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/migrations/__init__.py b/lms/djangoapps/shoppingcart/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 71a8362390..4b8ac259dd 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1,3 +1,161 @@ +import pytz +import logging +from datetime import datetime from django.db import models +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth.models import User +from student.views import course_from_id +from student.models import CourseEnrollmentAllowed, CourseEnrollment +from statsd import statsd +log = logging.getLogger("shoppingcart") -# Create your models here. +ORDER_STATUSES = ( + ('cart', 'cart'), + ('purchased', 'purchased'), + ('refunded', 'refunded'), # Not used for now +) + +class Order(models.Model): + """ + This is the model for an order. Before purchase, an Order and its related OrderItems are used + as the shopping cart. + THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart' PER USER. + """ + user = models.ForeignKey(User, db_index=True) + status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) + # Because we allow an external service to tell us when something is purchased, and our order numbers + # are their pk and therefore predicatble, let's protect against + # forged/replayed replies with a nonce. + nonce = models.CharField(max_length=128) + purchase_time = models.DateTimeField(null=True, blank=True) + + @classmethod + def get_cart_for_user(cls, user): + """ + Use this to enforce the property that at most 1 order per user has status = 'cart' + """ + order, created = cls.objects.get_or_create(user=user, status='cart') + return order + + @property + def total_cost(self): + return sum([i.line_cost for i in self.orderitem_set.all()]) + + def purchase(self): + """ + Call to mark this order as purchased. Iterates through its OrderItems and calls + their purchased_callback + """ + self.status = 'purchased' + self.purchase_time = datetime.now(pytz.utc) + self.save() + for item in self.orderitem_set.all(): + item.status = 'purchased' + item.purchased_callback() + item.save() + + +class OrderItem(models.Model): + """ + This is the basic interface for order items. + Order items are line items that fill up the shopping carts and orders. + + Each implementation of OrderItem should provide its own purchased_callback as + a method. + """ + order = models.ForeignKey(Order, db_index=True) + # this is denormalized, but convenient for SQL queries for reports, etc. user should always be = order.user + user = models.ForeignKey(User, db_index=True) + # this is denormalized, but convenient for SQL queries for reports, etc. status should always be = order.status + status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) + qty = models.IntegerField(default=1) + unit_cost = models.FloatField(default=0.0) + line_cost = models.FloatField(default=0.0) # qty * unit_cost + line_desc = models.CharField(default="Misc. Item", max_length=1024) + + def add_to_order(self, *args, **kwargs): + """ + A suggested convenience function for subclasses. + """ + raise NotImplementedError + + def purchased_callback(self): + """ + This is called on each inventory item in the shopping cart when the + purchase goes through. + + NOTE: We want to provide facilities for doing something like + for item in OrderItem.objects.filter(order_id=order_id): + item.purchased_callback() + + Unfortunately the QuerySet used determines the class to be OrderItem, and not its most specific + subclasses. That means this parent class implementation of purchased_callback needs to act as + a dispatcher to call the callback the proper subclasses, and as such it needs to know about all subclasses. + So please add + """ + for classname, lc_classname in ORDER_ITEM_SUBTYPES: + try: + sub_instance = getattr(self,lc_classname) + sub_instance.purchased_callback() + except (ObjectDoesNotExist, AttributeError): + log.exception('Cannot call purchase_callback on non-existent subclass attribute {0} of OrderItem'\ + .format(lc_classname)) + pass + +# Each entry is a tuple of ('ModelName', 'lower_case_model_name') +# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for +# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem +ORDER_ITEM_SUBTYPES = [ + ('PaidCourseRegistration', 'paidcourseregistration') +] + + + +class PaidCourseRegistration(OrderItem): + """ + This is an inventory item for paying for a course registration + """ + course_id = models.CharField(max_length=128, db_index=True) + + @classmethod + def add_to_order(cls, order, course_id, cost): + """ + A standardized way to create these objects, with sensible defaults filled in. + Will update the cost if called on an order that already carries the course. + + Returns the order item + """ + # TODO: Possibly add checking for whether student is already enrolled in course + course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) + item.status = order.status + item.qty = 1 + item.unit_cost = cost + item.line_cost = cost + item.line_desc = "Registration for Course {0}".format(course_id) + item.save() + return item + + def purchased_callback(self): + """ + When purchased, this should enroll the user in the course. We are assuming that + course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found in + CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment + would in fact be quite silly since there's a clear back door. + """ + course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for + # whatever reason. + # Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency + # with rest of codebase. + CourseEnrollmentAllowed.objects.get_or_create(email=self.user.email, course_id=self.course_id, auto_enroll=True) + CourseEnrollment.objects.get_or_create(user=self.user, course_id=self.course_id) + + log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) + org, course_num, run = self.course_id.split("/") + statsd.increment("shoppingcart.PaidCourseRegistration.purchased_callback.enrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 47bd3c4c3d..80653f93cb 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -3,7 +3,8 @@ from django.conf.urls import patterns, include, url urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^$','show_cart'), url(r'^(?P[^/]+/[^/]+/[^/]+)/$','test'), - url(r'^add/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), + url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), + url(r'^purchased/$', 'purchased'), ) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index a2680fd845..4c2a4dd091 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -7,10 +7,10 @@ from hashlib import sha1 from django.conf import settings from collections import OrderedDict -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response -from .inventory_types import * +from .models import * log = logging.getLogger("shoppingcart") @@ -20,50 +20,62 @@ def test(request, course_id): item1.purchased_callback(request.user.id) return HttpResponse('OK') +@login_required +def purchased(request): + #verify() -- signatures, total cost match up, etc. Need error handling code ( + # If verify fails probaly need to display a contact email/number) + cart = Order.get_cart_for_user(request.user) + cart.purchase() + return HttpResponseRedirect('/') + @login_required def add_course_to_cart(request, course_id): - cart = request.session.get('shopping_cart', []) - course_ids_in_cart = [i.course_id for i in cart if isinstance(i, PaidCourseRegistration)] - if course_id not in course_ids_in_cart: - # TODO: Catch 500 here for course that does not exist, period - item = PaidCourseRegistration(course_id, 200) - cart.append(item) - request.session['shopping_cart'] = cart - return HttpResponse('Added') - else: - return HttpResponse("Item exists, not adding") + cart = Order.get_cart_for_user(request.user) + # TODO: Catch 500 here for course that does not exist, period + PaidCourseRegistration.add_to_order(cart, course_id, 200) + return HttpResponse("Added") @login_required def show_cart(request): - cart = request.session.get('shopping_cart', []) - total_cost = "{0:0.2f}".format(sum([i.line_cost for i in cart])) + cart = Order.get_cart_for_user(request.user) + total_cost = cart.total_cost + cart_items = cart.orderitem_set.all() params = OrderedDict() params['amount'] = total_cost params['currency'] = 'usd' params['orderPage_transactionType'] = 'sale' - params['orderNumber'] = "{0:d}".format(random.randint(1, 10000)) + params['orderNumber'] = "{0:d}".format(cart.id) + params['billTo_email'] = request.user.email + idx=1 + for item in cart_items: + prefix = "item_{0:d}_".format(idx) + params[prefix+'productSKU'] = "{0:d}".format(item.id) + params[prefix+'quantity'] = item.qty + params[prefix+'productName'] = item.line_desc + params[prefix+'unitPrice'] = item.unit_cost + params[prefix+'taxAmount'] = "0.00" signed_param_dict = cybersource_sign(params) return render_to_response("shoppingcart/list.html", - {'shoppingcart_items': cart, + {'shoppingcart_items': cart_items, 'total_cost': total_cost, 'params': signed_param_dict, }) @login_required def clear_cart(request): - request.session['shopping_cart'] = [] + cart = Order.get_cart_for_user(request.user) + cart.orderitem_set.all().delete() return HttpResponse('Cleared') @login_required def remove_item(request): - # doing this with indexes to replicate the function that generated the list on the HTML page - item_idx = request.REQUEST.get('idx', 'blank') + item_id = request.REQUEST.get('id', '-1') try: - cart = request.session.get('shopping_cart', []) - cart.pop(int(item_idx)) - request.session['shopping_cart'] = cart - except IndexError, ValueError: - log.exception('Cannot remove element at index {0} from cart'.format(item_idx)) + item = OrderItem.objects.get(id=item_id, status='cart') + if item.user == request.user: + item.delete() + except OrderItem.DoesNotExist: + log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id)) return HttpResponse('OK') diff --git a/lms/envs/common.py b/lms/envs/common.py index d397371cc2..420068f7bd 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -778,6 +778,9 @@ INSTALLED_APPS = ( 'rest_framework', 'user_api', + # shopping cart + 'shoppingcart', + # Notification preferences setting 'notification_prefs', diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index a1f785c8b4..a37aa0fb5f 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -13,9 +13,9 @@ QtyDescriptionUnit PricePrice - % for idx,item in enumerate(shoppingcart_items): + % for item in shoppingcart_items: ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost} - [x] + [x] % endfor Total Cost ${total_cost} @@ -41,7 +41,7 @@ $('a.remove_line_item').click(function(event) { event.preventDefault(); var post_url = "${reverse('shoppingcart.views.remove_item')}"; - $.post(post_url, {idx:$(this).data('item-idx')}) + $.post(post_url, {id:$(this).data('item-id')}) .always(function(data){ location.reload(true); }); From 1f6bdca6ddc94c5484049fae02e80769fa681135 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 8 Aug 2013 21:55:08 -0700 Subject: [PATCH 010/185] add Validation function for cybersource receipt POST --- lms/djangoapps/shoppingcart/urls.py | 1 + lms/djangoapps/shoppingcart/views.py | 55 ++++++++++++++++++++++------ lms/templates/shoppingcart/list.html | 7 ++-- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 80653f93cb..99d5217813 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -7,4 +7,5 @@ urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^purchased/$', 'purchased'), + url(r'^receipt/$', 'receipt'), ) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 4c2a4dd091..f0558d3003 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -4,16 +4,22 @@ import time import hmac import binascii from hashlib import sha1 +from collections import OrderedDict from django.conf import settings -from collections import OrderedDict from django.http import HttpResponse, HttpResponseRedirect +from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response from .models import * log = logging.getLogger("shoppingcart") +shared_secret = settings.CYBERSOURCE.get('SHARED_SECRET','') +merchant_id = settings.CYBERSOURCE.get('MERCHANT_ID','') +serial_number = settings.CYBERSOURCE.get('SERIAL_NUMBER','') +orderPage_version = settings.CYBERSOURCE.get('ORDERPAGE_VERSION','7') + def test(request, course_id): item1 = PaidCourseRegistration(course_id, 200) @@ -39,9 +45,11 @@ def add_course_to_cart(request, course_id): def show_cart(request): cart = Order.get_cart_for_user(request.user) total_cost = cart.total_cost + amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() params = OrderedDict() - params['amount'] = total_cost + params['comment'] = 'Stanford OpenEdX Purchase' + params['amount'] = amount params['currency'] = 'usd' params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) @@ -57,7 +65,7 @@ def show_cart(request): signed_param_dict = cybersource_sign(params) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, - 'total_cost': total_cost, + 'amount': amount, 'params': signed_param_dict, }) @@ -78,27 +86,50 @@ def remove_item(request): log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id)) return HttpResponse('OK') +@csrf_exempt +def receipt(request): + """ + Receives the POST-back from Cybersource and performs the validation and displays a receipt + and does some other stuff + """ + if cybersource_verify(request.POST): + return HttpResponse("Validated") + else: + return HttpResponse("Not Validated") + +def cybersource_hash(value): + """ + Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page + """ + hash_obj = hmac.new(shared_secret, value, sha1) + return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want + def cybersource_sign(params): """ params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ - shared_secret = settings.CYBERSOURCE.get('SHARED_SECRET','') - merchant_id = settings.CYBERSOURCE.get('MERCHANT_ID','') - serial_number = settings.CYBERSOURCE.get('SERIAL_NUMBER','') - orderPage_version = settings.CYBERSOURCE.get('ORDERPAGE_VERSION','7') params['merchantID'] = merchant_id params['orderPage_timestamp'] = int(time.time()*1000) params['orderPage_version'] = orderPage_version params['orderPage_serialNumber'] = serial_number fields = ",".join(params.keys()) values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) - fields_hash_obj = hmac.new(shared_secret, fields, sha1) - fields_sig = binascii.b2a_base64(fields_hash_obj.digest())[:-1] # last character is a '\n', which we don't want + fields_sig = cybersource_hash(fields) values += ",signedFieldsPublicSignature=" + fields_sig - values_hash_obj = hmac.new(shared_secret, values, sha1) - params['orderPage_signaturePublic'] = binascii.b2a_base64(values_hash_obj.digest())[:-1] + params['orderPage_signaturePublic'] = cybersource_hash(values) params['orderPage_signedFields'] = fields - return params \ No newline at end of file + return params + +def cybersource_verify(params): + signed_fields = params.get('signedFields', '').split(',') + data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) + signed_fields_sig = cybersource_hash(params.get('signedFields', '')) + data += ",signedFieldsPublicSignature=" + signed_fields_sig + returned_sig = params.get('signedDataPublicSignature','') + if not returned_sig: + return False + return cybersource_hash(data) == returned_sig + diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index a37aa0fb5f..0ff97aa6ae 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -14,11 +14,12 @@ % for item in shoppingcart_items: - ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost} + ${item.qty}${item.line_desc} + ${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)} [x] % endfor - Total Cost - ${total_cost} + Total Amount + ${amount} From ab1452cb1ad27b04baef4703c1593d2e150f89f2 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 8 Aug 2013 23:01:29 -0700 Subject: [PATCH 011/185] add Order model fields for receipt generation --- ...d_field_order_bill_to_first__add_field_.py | 183 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 34 +++- lms/djangoapps/shoppingcart/views.py | 2 +- lms/templates/shoppingcart/list.html | 3 +- 4 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py diff --git a/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py b/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py new file mode 100644 index 0000000000..940116f7b8 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py @@ -0,0 +1,183 @@ +# -*- 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): + # Deleting field 'Order.nonce' + db.delete_column('shoppingcart_order', 'nonce') + + # Adding field 'Order.bill_to_first' + db.add_column('shoppingcart_order', 'bill_to_first', + self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_last' + db.add_column('shoppingcart_order', 'bill_to_last', + self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_street1' + db.add_column('shoppingcart_order', 'bill_to_street1', + self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_street2' + db.add_column('shoppingcart_order', 'bill_to_street2', + self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_city' + db.add_column('shoppingcart_order', 'bill_to_city', + self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_postalcode' + db.add_column('shoppingcart_order', 'bill_to_postalcode', + self.gf('django.db.models.fields.CharField')(max_length=16, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_country' + db.add_column('shoppingcart_order', 'bill_to_country', + self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_ccnum' + db.add_column('shoppingcart_order', 'bill_to_ccnum', + self.gf('django.db.models.fields.CharField')(max_length=8, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_cardtype' + db.add_column('shoppingcart_order', 'bill_to_cardtype', + self.gf('django.db.models.fields.CharField')(max_length=32, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.processor_reply_dump' + db.add_column('shoppingcart_order', 'processor_reply_dump', + self.gf('django.db.models.fields.TextField')(null=True, blank=True), + keep_default=False) + + # Adding field 'OrderItem.currency' + db.add_column('shoppingcart_orderitem', 'currency', + self.gf('django.db.models.fields.CharField')(default='usd', max_length=8), + keep_default=False) + + + def backwards(self, orm): + # Adding field 'Order.nonce' + db.add_column('shoppingcart_order', 'nonce', + self.gf('django.db.models.fields.CharField')(default='defaultNonce', max_length=128), + keep_default=False) + + # Deleting field 'Order.bill_to_first' + db.delete_column('shoppingcart_order', 'bill_to_first') + + # Deleting field 'Order.bill_to_last' + db.delete_column('shoppingcart_order', 'bill_to_last') + + # Deleting field 'Order.bill_to_street1' + db.delete_column('shoppingcart_order', 'bill_to_street1') + + # Deleting field 'Order.bill_to_street2' + db.delete_column('shoppingcart_order', 'bill_to_street2') + + # Deleting field 'Order.bill_to_city' + db.delete_column('shoppingcart_order', 'bill_to_city') + + # Deleting field 'Order.bill_to_postalcode' + db.delete_column('shoppingcart_order', 'bill_to_postalcode') + + # Deleting field 'Order.bill_to_country' + db.delete_column('shoppingcart_order', 'bill_to_country') + + # Deleting field 'Order.bill_to_ccnum' + db.delete_column('shoppingcart_order', 'bill_to_ccnum') + + # Deleting field 'Order.bill_to_cardtype' + db.delete_column('shoppingcart_order', 'bill_to_cardtype') + + # Deleting field 'Order.processor_reply_dump' + db.delete_column('shoppingcart_order', 'processor_reply_dump') + + # Deleting field 'OrderItem.currency' + db.delete_column('shoppingcart_orderitem', 'currency') + + + 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'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 4b8ac259dd..f9da7082e1 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -19,20 +19,29 @@ class Order(models.Model): """ This is the model for an order. Before purchase, an Order and its related OrderItems are used as the shopping cart. - THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart' PER USER. + FOR ANY USER, THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart'. """ user = models.ForeignKey(User, db_index=True) status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) - # Because we allow an external service to tell us when something is purchased, and our order numbers - # are their pk and therefore predicatble, let's protect against - # forged/replayed replies with a nonce. - nonce = models.CharField(max_length=128) purchase_time = models.DateTimeField(null=True, blank=True) + # Now we store data needed to generate a reasonable receipt + # These fields only make sense after the purchase + bill_to_first = models.CharField(max_length=64, null=True, blank=True) + bill_to_last = models.CharField(max_length=64, null=True, blank=True) + bill_to_street1 = models.CharField(max_length=128, null=True, blank=True) + bill_to_street2 = models.CharField(max_length=128, null=True, blank=True) + bill_to_city = models.CharField(max_length=64, null=True, blank=True) + bill_to_postalcode = models.CharField(max_length=16, null=True, blank=True) + bill_to_country = models.CharField(max_length=64, null=True, blank=True) + bill_to_ccnum = models.CharField(max_length=8, null=True, blank=True) # last 4 digits + bill_to_cardtype = models.CharField(max_length=32, null=True, blank=True) + # a JSON dump of the CC processor response, for completeness + processor_reply_dump = models.TextField(null=True, blank=True) @classmethod def get_cart_for_user(cls, user): """ - Use this to enforce the property that at most 1 order per user has status = 'cart' + Always use this to preserve the property that at most 1 order per user has status = 'cart' """ order, created = cls.objects.get_or_create(user=user, status='cart') return order @@ -41,6 +50,15 @@ class Order(models.Model): def total_cost(self): return sum([i.line_cost for i in self.orderitem_set.all()]) + @property + def currency(self): + """Assumes that all cart items are in the same currency""" + items = self.orderitem_set.all() + if not items: + return 'usd' + else: + return items[0].currency + def purchase(self): """ Call to mark this order as purchased. Iterates through its OrderItems and calls @@ -72,6 +90,7 @@ class OrderItem(models.Model): unit_cost = models.FloatField(default=0.0) line_cost = models.FloatField(default=0.0) # qty * unit_cost line_desc = models.CharField(default="Misc. Item", max_length=1024) + currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes def add_to_order(self, *args, **kwargs): """ @@ -118,7 +137,7 @@ class PaidCourseRegistration(OrderItem): course_id = models.CharField(max_length=128, db_index=True) @classmethod - def add_to_order(cls, order, course_id, cost): + def add_to_order(cls, order, course_id, cost, currency='usd'): """ A standardized way to create these objects, with sensible defaults filled in. Will update the cost if called on an order that already carries the course. @@ -134,6 +153,7 @@ class PaidCourseRegistration(OrderItem): item.unit_cost = cost item.line_cost = cost item.line_desc = "Registration for Course {0}".format(course_id) + item.currency = currency item.save() return item diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index f0558d3003..d81de1a68c 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -50,7 +50,7 @@ def show_cart(request): params = OrderedDict() params['comment'] = 'Stanford OpenEdX Purchase' params['amount'] = amount - params['currency'] = 'usd' + params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) params['billTo_email'] = request.user.email diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 0ff97aa6ae..7c3e7052ae 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -10,12 +10,13 @@ % if shoppingcart_items: - + % for item in shoppingcart_items: + % endfor From cf9d42772fdf080f878653f3838dbbf98de0447f Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Fri, 9 Aug 2013 11:01:32 -0700 Subject: [PATCH 012/185] factor out cybersource processor from cart --- lms/djangoapps/shoppingcart/models.py | 3 +- .../shoppingcart/processors/CyberSource.py | 81 +++++++++++++++++++ .../shoppingcart/processors/__init__.py | 34 ++++++++ lms/djangoapps/shoppingcart/views.py | 73 ++--------------- lms/envs/aws.py | 2 +- lms/envs/common.py | 16 ++-- .../shoppingcart/cybersource_form.html | 6 ++ lms/templates/shoppingcart/list.html | 7 +- 8 files changed, 140 insertions(+), 82 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/processors/CyberSource.py create mode 100644 lms/djangoapps/shoppingcart/processors/__init__.py create mode 100644 lms/templates/shoppingcart/cybersource_form.html diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index f9da7082e1..0bf4e3934e 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -4,6 +4,7 @@ from datetime import datetime from django.db import models from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User +from courseware.courses import course_image_url, get_course_about_section from student.views import course_from_id from student.models import CourseEnrollmentAllowed, CourseEnrollment from statsd import statsd @@ -152,7 +153,7 @@ class PaidCourseRegistration(OrderItem): item.qty = 1 item.unit_cost = cost item.line_cost = cost - item.line_desc = "Registration for Course {0}".format(course_id) + item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) item.currency = currency item.save() return item diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py new file mode 100644 index 0000000000..98026e6a84 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -0,0 +1,81 @@ +### Implementation of support for the Cybersource Credit card processor +### The name of this file should be used as the key of the dict in the CC_PROCESSOR setting + +import time +import hmac +import binascii +from collections import OrderedDict +from hashlib import sha1 +from django.conf import settings +from mitxmako.shortcuts import render_to_string + +shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') +merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') +serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') +orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') +purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') + +def hash(value): + """ + Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page + """ + hash_obj = hmac.new(shared_secret, value, sha1) + return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want + + +def sign(params): + """ + params needs to be an ordered dict, b/c cybersource documentation states that order is important. + Reverse engineered from PHP version provided by cybersource + """ + params['merchantID'] = merchant_id + params['orderPage_timestamp'] = int(time.time()*1000) + params['orderPage_version'] = orderPage_version + params['orderPage_serialNumber'] = serial_number + fields = ",".join(params.keys()) + values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) + fields_sig = hash(fields) + values += ",signedFieldsPublicSignature=" + fields_sig + params['orderPage_signaturePublic'] = hash(values) + params['orderPage_signedFields'] = fields + + return params + +def verify(params): + """ + Verify the signatures accompanying the POST back from Cybersource Hosted Order Page + """ + signed_fields = params.get('signedFields', '').split(',') + data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) + signed_fields_sig = hash(params.get('signedFields', '')) + data += ",signedFieldsPublicSignature=" + signed_fields_sig + returned_sig = params.get('signedDataPublicSignature','') + if not returned_sig: + return False + return hash(data) == returned_sig + +def render_purchase_form_html(cart, user): + total_cost = cart.total_cost + amount = "{0:0.2f}".format(total_cost) + cart_items = cart.orderitem_set.all() + params = OrderedDict() + params['comment'] = 'Stanford OpenEdX Purchase' + params['amount'] = amount + params['currency'] = cart.currency + params['orderPage_transactionType'] = 'sale' + params['orderNumber'] = "{0:d}".format(cart.id) + params['billTo_email'] = user.email + idx=1 + for item in cart_items: + prefix = "item_{0:d}_".format(idx) + params[prefix+'productSKU'] = "{0:d}".format(item.id) + params[prefix+'quantity'] = item.qty + params[prefix+'productName'] = item.line_desc + params[prefix+'unitPrice'] = item.unit_cost + params[prefix+'taxAmount'] = "0.00" + signed_param_dict = sign(params) + + return render_to_string('shoppingcart/cybersource_form.html', { + 'action': purchase_endpoint, + 'params': signed_param_dict, + }) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py new file mode 100644 index 0000000000..de567976be --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -0,0 +1,34 @@ +from django.conf import settings + +### Now code that determines, using settings, which actual processor implementation we're using. +processor_name = settings.CC_PROCESSOR.keys()[0] +module = __import__('shoppingcart.processors.' + processor_name, + fromlist=['sign', 'verify', 'render_purchase_form_html']) + +def sign(*args, **kwargs): + """ + Given a dict (or OrderedDict) of parameters to send to the + credit card processor, signs them in the manner expected by + the processor + + Returns a dict containing the signature + """ + return module.sign(*args, **kwargs) + +def verify(*args, **kwargs): + """ + Given a dict (or OrderedDict) of parameters to returned by the + credit card processor, verifies them in the manner specified by + the processor + + Returns a boolean + """ + return module.sign(*args, **kwargs) + +def render_purchase_form_html(*args, **kwargs): + """ + Given a shopping cart, + Renders the HTML form for display on user's browser, which POSTS to Hosted Processors + Returns the HTML as a string + """ + return module.render_purchase_form_html(*args, **kwargs) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index d81de1a68c..00e6db0e7d 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,26 +1,14 @@ import logging -import random -import time -import hmac -import binascii -from hashlib import sha1 -from collections import OrderedDict -from django.conf import settings from django.http import HttpResponse, HttpResponseRedirect from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response from .models import * +from .processors import verify, render_purchase_form_html log = logging.getLogger("shoppingcart") -shared_secret = settings.CYBERSOURCE.get('SHARED_SECRET','') -merchant_id = settings.CYBERSOURCE.get('MERCHANT_ID','') -serial_number = settings.CYBERSOURCE.get('SERIAL_NUMBER','') -orderPage_version = settings.CYBERSOURCE.get('ORDERPAGE_VERSION','7') - - def test(request, course_id): item1 = PaidCourseRegistration(course_id, 200) item1.purchased_callback(request.user.id) @@ -47,26 +35,11 @@ def show_cart(request): total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() - params = OrderedDict() - params['comment'] = 'Stanford OpenEdX Purchase' - params['amount'] = amount - params['currency'] = cart.currency - params['orderPage_transactionType'] = 'sale' - params['orderNumber'] = "{0:d}".format(cart.id) - params['billTo_email'] = request.user.email - idx=1 - for item in cart_items: - prefix = "item_{0:d}_".format(idx) - params[prefix+'productSKU'] = "{0:d}".format(item.id) - params[prefix+'quantity'] = item.qty - params[prefix+'productName'] = item.line_desc - params[prefix+'unitPrice'] = item.unit_cost - params[prefix+'taxAmount'] = "0.00" - signed_param_dict = cybersource_sign(params) + form_html = render_purchase_form_html(cart, request.user) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, 'amount': amount, - 'params': signed_param_dict, + 'form_html': form_html, }) @login_required @@ -89,47 +62,11 @@ def remove_item(request): @csrf_exempt def receipt(request): """ - Receives the POST-back from Cybersource and performs the validation and displays a receipt + Receives the POST-back from processor and performs the validation and displays a receipt and does some other stuff """ - if cybersource_verify(request.POST): + if verify(request.POST.dict()): return HttpResponse("Validated") else: return HttpResponse("Not Validated") -def cybersource_hash(value): - """ - Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page - """ - hash_obj = hmac.new(shared_secret, value, sha1) - return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want - - -def cybersource_sign(params): - """ - params needs to be an ordered dict, b/c cybersource documentation states that order is important. - Reverse engineered from PHP version provided by cybersource - """ - params['merchantID'] = merchant_id - params['orderPage_timestamp'] = int(time.time()*1000) - params['orderPage_version'] = orderPage_version - params['orderPage_serialNumber'] = serial_number - fields = ",".join(params.keys()) - values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) - fields_sig = cybersource_hash(fields) - values += ",signedFieldsPublicSignature=" + fields_sig - params['orderPage_signaturePublic'] = cybersource_hash(values) - params['orderPage_signedFields'] = fields - - return params - -def cybersource_verify(params): - signed_fields = params.get('signedFields', '').split(',') - data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) - signed_fields_sig = cybersource_hash(params.get('signedFields', '')) - data += ",signedFieldsPublicSignature=" + signed_fields_sig - returned_sig = params.get('signedDataPublicSignature','') - if not returned_sig: - return False - return cybersource_hash(data) == returned_sig - diff --git a/lms/envs/aws.py b/lms/envs/aws.py index cc0e956b0c..f7c6db39f9 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -191,7 +191,7 @@ if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False) -CYBERSOURCE = AUTH_TOKENS.get('CYBERSOURCE', CYBERSOURCE) +CC_PROCESSOR = AUTH_TOKENS.get('CC_PROCESSOR', CC_PROCESSOR) SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] diff --git a/lms/envs/common.py b/lms/envs/common.py index 420068f7bd..d066259fe5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -431,12 +431,16 @@ ZENDESK_URL = None ZENDESK_USER = None ZENDESK_API_KEY = None -##### CyberSource Payment parameters ##### -CYBERSOURCE = { - 'SHARED_SECRET': '', - 'MERCHANT_ID' : '', - 'SERIAL_NUMBER' : '', - 'ORDERPAGE_VERSION': '7', +##### shoppingcart Payment ##### +##### Using cybersource by default ##### +CC_PROCESSOR = { + 'CyberSource' : { + 'SHARED_SECRET': '', + 'MERCHANT_ID' : '', + 'SERIAL_NUMBER' : '', + 'ORDERPAGE_VERSION': '7', + 'PURCHASE_ENDPOINT': '', + } } ################################# open ended grading config ##################### diff --git a/lms/templates/shoppingcart/cybersource_form.html b/lms/templates/shoppingcart/cybersource_form.html new file mode 100644 index 0000000000..b29ea79aa1 --- /dev/null +++ b/lms/templates/shoppingcart/cybersource_form.html @@ -0,0 +1,6 @@ +
    + % for pk, pv in params.iteritems(): + + % endfor + + diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 7c3e7052ae..35623d8b5b 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -25,12 +25,7 @@
    QtyDescriptionUnit PricePrice
    QtyDescriptionUnit PricePriceCurrency
    ${item.qty}${item.line_desc} ${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()} [x]
    Total Amount
    -
    - % for pk, pv in params.iteritems(): - - % endfor - -
    + ${form_html} % else:

    You have selected no items for purchase.

    % endif From 0b8f41443fe247bd0de0fd7afed636caf7b7adf2 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Fri, 9 Aug 2013 18:36:35 -0700 Subject: [PATCH 013/185] Lots more verification of CyberSource reply + receipt generation --- ...003_auto__add_field_order_bill_to_state.py | 96 +++++++++++++++ lms/djangoapps/shoppingcart/models.py | 17 ++- .../shoppingcart/processors/CyberSource.py | 112 +++++++++++++++++- .../shoppingcart/processors/__init__.py | 22 +++- .../shoppingcart/processors/exceptions.py | 11 ++ lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 57 +++++++-- lms/envs/aws.py | 2 +- lms/envs/common.py | 1 + lms/templates/shoppingcart/list.html | 7 +- lms/templates/shoppingcart/receipt.html | 56 +++++++++ 11 files changed, 365 insertions(+), 19 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py create mode 100644 lms/djangoapps/shoppingcart/processors/exceptions.py create mode 100644 lms/templates/shoppingcart/receipt.html diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py new file mode 100644 index 0000000000..85923794b6 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.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 field 'Order.bill_to_state' + db.add_column('shoppingcart_order', 'bill_to_state', + self.gf('django.db.models.fields.CharField')(max_length=8, null=True, blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Order.bill_to_state' + db.delete_column('shoppingcart_order', 'bill_to_state') + + + 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'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 0bf4e3934e..052ecfb888 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -32,6 +32,7 @@ class Order(models.Model): bill_to_street1 = models.CharField(max_length=128, null=True, blank=True) bill_to_street2 = models.CharField(max_length=128, null=True, blank=True) bill_to_city = models.CharField(max_length=64, null=True, blank=True) + bill_to_state = models.CharField(max_length=8, null=True, blank=True) bill_to_postalcode = models.CharField(max_length=16, null=True, blank=True) bill_to_country = models.CharField(max_length=64, null=True, blank=True) bill_to_ccnum = models.CharField(max_length=8, null=True, blank=True) # last 4 digits @@ -49,7 +50,7 @@ class Order(models.Model): @property def total_cost(self): - return sum([i.line_cost for i in self.orderitem_set.all()]) + return sum([i.line_cost for i in self.orderitem_set.filter(status=self.status)]) @property def currency(self): @@ -60,13 +61,25 @@ class Order(models.Model): else: return items[0].currency - def purchase(self): + def purchase(self, first='', last='', street1='', street2='', city='', state='', postalcode='', + country='', ccnum='', cardtype='', processor_reply_dump=''): """ Call to mark this order as purchased. Iterates through its OrderItems and calls their purchased_callback """ self.status = 'purchased' self.purchase_time = datetime.now(pytz.utc) + self.bill_to_first = first + self.bill_to_last = last + self.bill_to_street1 = street1 + self.bill_to_street2 = street2 + self.bill_to_city = city + self.bill_to_state = state + self.bill_to_postalcode = postalcode + self.bill_to_country = country + self.bill_to_ccnum = ccnum + self.bill_to_cardtype = cardtype + self.processor_reply_dump = processor_reply_dump self.save() for item in self.orderitem_set.all(): item.status = 'purchased' diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 98026e6a84..17e1511ac6 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -1,13 +1,19 @@ ### Implementation of support for the Cybersource Credit card processor ### The name of this file should be used as the key of the dict in the CC_PROCESSOR setting +### Implementes interface as specified by __init__.py import time import hmac import binascii -from collections import OrderedDict +import re +import json +from collections import OrderedDict, defaultdict from hashlib import sha1 from django.conf import settings +from django.utils.translation import ugettext as _ from mitxmako.shortcuts import render_to_string +from shoppingcart.models import Order +from .exceptions import CCProcessorDataException, CCProcessorWrongAmountException shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') @@ -41,6 +47,7 @@ def sign(params): return params + def verify(params): """ Verify the signatures accompanying the POST back from Cybersource Hosted Order Page @@ -54,7 +61,11 @@ def verify(params): return False return hash(data) == returned_sig + def render_purchase_form_html(cart, user): + """ + Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource + """ total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() @@ -64,7 +75,6 @@ def render_purchase_form_html(cart, user): params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) - params['billTo_email'] = user.email idx=1 for item in cart_items: prefix = "item_{0:d}_".format(idx) @@ -78,4 +88,100 @@ def render_purchase_form_html(cart, user): return render_to_string('shoppingcart/cybersource_form.html', { 'action': purchase_endpoint, 'params': signed_param_dict, - }) \ No newline at end of file + }) + + +def payment_accepted(params): + """ + Check that cybersource has accepted the payment + """ + #make sure required keys are present and convert their values to the right type + valid_params = {} + for key, type in [('orderNumber', int), + ('ccAuthReply_amount', float), + ('orderCurrency', str), + ('decision', str)]: + if key not in params: + raise CCProcessorDataException( + _("The payment processor did not return a required parameter: {0}".format(key)) + ) + try: + valid_params[key] = type(params[key]) + except ValueError: + raise CCProcessorDataException( + _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) + ) + + try: + order = Order.objects.get(id=valid_params['orderNumber']) + except Order.DoesNotExist: + raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system.")) + + if valid_params['decision'] == 'ACCEPT': + if valid_params['ccAuthReply_amount'] == order.total_cost and valid_params['orderCurrency'] == order.currency: + return {'accepted': True, + 'amt_charged': valid_params['ccAuthReply_amount'], + 'currency': valid_params['orderCurrency'], + 'order': order} + else: + raise CCProcessorWrongAmountException( + _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}."\ + .format(valid_params['ccAuthReply_amount'], valid_params['orderCurrency'], + order.total_cost, order.currency)) + ) + else: + return {'accepted': False, + 'amt_charged': 0, + 'currency': 'usd', + 'order': None} + + +def record_purchase(params, order): + """ + Record the purchase and run purchased_callbacks + """ + ccnum_str = params.get('card_accountNumber', '') + m = re.search("\d", ccnum_str) + if m: + ccnum = ccnum_str[m.start():] + else: + ccnum = "####" + + order.purchase( + first=params.get('billTo_firstName', ''), + last=params.get('billTo_lastName', ''), + street1=params.get('billTo_street1', ''), + street2=params.get('billTo_street2', ''), + city=params.get('billTo_city', ''), + state=params.get('billTo_state', ''), + country=params.get('billTo_country', ''), + postalcode=params.get('billTo_postalCode',''), + ccnum=ccnum, + cardtype=CARDTYPE_MAP[params.get('card_cardType', 'UNKNOWN')], + processor_reply_dump=json.dumps(params) + ) + + +CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") +CARDTYPE_MAP.update( + { + '001': 'Visa', + '002': 'MasterCard', + '003': 'American Express', + '004': 'Discover', + '005': 'Diners Club', + '006': 'Carte Blanche', + '007': 'JCB', + '014': 'EnRoute', + '021': 'JAL', + '024': 'Maestro', + '031': 'Delta', + '033': 'Visa Electron', + '034': 'Dankort', + '035': 'Laser', + '036': 'Carte Bleue', + '037': 'Carta Si', + '042': 'Maestro', + '043': 'GE Money UK card' + } +) diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index de567976be..520c353535 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -3,7 +3,12 @@ from django.conf import settings ### Now code that determines, using settings, which actual processor implementation we're using. processor_name = settings.CC_PROCESSOR.keys()[0] module = __import__('shoppingcart.processors.' + processor_name, - fromlist=['sign', 'verify', 'render_purchase_form_html']) + fromlist=['sign', + 'verify', + 'render_purchase_form_html' + 'payment_accepted', + 'record_purchase', + ]) def sign(*args, **kwargs): """ @@ -32,3 +37,18 @@ def render_purchase_form_html(*args, **kwargs): Returns the HTML as a string """ return module.render_purchase_form_html(*args, **kwargs) + +def payment_accepted(*args, **kwargs): + """ + Given params returned by the CC processor, check that processor has accepted the payment + Returns a dict of {accepted:bool, amt_charged:float, currency:str, order:Order} + """ + return module.payment_accepted(*args, **kwargs) + +def record_purchase(*args, **kwargs): + """ + Given params returned by the CC processor, record that the purchase has occurred in + the database and also run callbacks + """ + return module.record_purchase(*args, **kwargs) + diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py new file mode 100644 index 0000000000..bc132a3d54 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -0,0 +1,11 @@ +class PaymentException(Exception): + pass + +class CCProcessorException(PaymentException): + pass + +class CCProcessorDataException(CCProcessorException): + pass + +class CCProcessorWrongAmountException(PaymentException): + pass \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 99d5217813..58e51f0b40 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -7,5 +7,6 @@ urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^purchased/$', 'purchased'), - url(r'^receipt/$', 'receipt'), + url(r'^postpay_accept_callback/$', 'postpay_accept_callback'), + url(r'^receipt/(?P[0-9]*)/$', 'show_receipt'), ) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 00e6db0e7d..f5540aafbb 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,11 +1,13 @@ import logging -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.core.urlresolvers import reverse from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response from .models import * -from .processors import verify, render_purchase_form_html +from .processors import verify, payment_accepted, render_purchase_form_html, record_purchase +from .processors.exceptions import CCProcessorDataException, CCProcessorWrongAmountException log = logging.getLogger("shoppingcart") @@ -60,13 +62,52 @@ def remove_item(request): return HttpResponse('OK') @csrf_exempt -def receipt(request): +def postpay_accept_callback(request): """ Receives the POST-back from processor and performs the validation and displays a receipt and does some other stuff - """ - if verify(request.POST.dict()): - return HttpResponse("Validated") - else: - return HttpResponse("Not Validated") + HANDLES THE ACCEPT AND REVIEW CASES + """ + # TODO: Templates and logic for all error cases and the REVIEW CASE + params = request.POST.dict() + if verify(params): + try: + result = payment_accepted(params) + if result['accepted']: + # ACCEPTED CASE first + record_purchase(params, result['order']) + #render_receipt + return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) + else: + return HttpResponse("CC Processor has not accepted the payment.") + except CCProcessorWrongAmountException: + return HttpResponse("Charged the wrong amount, contact our user support") + except CCProcessorDataException: + return HttpResponse("Exception: the processor returned invalid data") + else: + return HttpResponse("There has been a communication problem blah blah. Not Validated") + +def show_receipt(request, ordernum): + """ + Displays a receipt for a particular order. + 404 if order is not yet purchased or request.user != order.user + """ + try: + order = Order.objects.get(id=ordernum) + except Order.DoesNotExist: + raise Http404('Order not found!') + + if order.user != request.user or order.status != 'purchased': + raise Http404('Order not found!') + + order_items = order.orderitem_set.all() + any_refunds = "refunded" in [i.status for i in order_items] + return render_to_response('shoppingcart/receipt.html', {'order': order, + 'order_items': order_items, + 'any_refunds': any_refunds}) + +def show_orders(request): + """ + Displays all orders of a user + """ diff --git a/lms/envs/aws.py b/lms/envs/aws.py index f7c6db39f9..f6eb45ec51 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -127,6 +127,7 @@ SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL) CONTACT_EMAIL = ENV_TOKENS.get('CONTACT_EMAIL', CONTACT_EMAIL) BUGS_EMAIL = ENV_TOKENS.get('BUGS_EMAIL', BUGS_EMAIL) +PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_EMAIL) #Theme overrides THEME_NAME = ENV_TOKENS.get('THEME_NAME', None) @@ -190,7 +191,6 @@ SEGMENT_IO_LMS_KEY = AUTH_TOKENS.get('SEGMENT_IO_LMS_KEY') if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False) - CC_PROCESSOR = AUTH_TOKENS.get('CC_PROCESSOR', CC_PROCESSOR) SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] diff --git a/lms/envs/common.py b/lms/envs/common.py index d066259fe5..7e4d23f065 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -432,6 +432,7 @@ ZENDESK_USER = None ZENDESK_API_KEY = None ##### shoppingcart Payment ##### +PAYMENT_SUPPORT_EMAIL = 'payment@edx.org' ##### Using cybersource by default ##### CC_PROCESSOR = { 'CyberSource' : { diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 35623d8b5b..677077ba2d 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -7,10 +7,11 @@ <%block name="title">${_("Your Shopping Cart")}
    +

    ${_("Your selected items:")}

    % if shoppingcart_items: - + ${_("")} % for item in shoppingcart_items: @@ -19,7 +20,7 @@ % endfor - + @@ -27,7 +28,7 @@ ${form_html} % else: -

    You have selected no items for purchase.

    +

    ${_("You have selected no items for purchase.")}

    % endif diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html new file mode 100644 index 0000000000..01de3b3f83 --- /dev/null +++ b/lms/templates/shoppingcart/receipt.html @@ -0,0 +1,56 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%! from django.conf import settings %> + +<%inherit file="../main.html" /> + +<%block name="title">${_("Receipt for Order")} ${order.id} + + + +
    +

    ${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}

    +

    ${_("Order #")}${order.id}

    +

    ${_("Date:")} ${order.purchase_time.date().isoformat()}

    +

    ${_("Items ordered:")}

    + +
    QtyDescriptionUnit PricePriceCurrency
    QuantityDescriptionUnit PricePriceCurrency
    ${item.currency.upper()} [x]
    Total Amount
    ${_("Total Amount")}
    ${amount}
    + + ${_("")} + + + % for item in order_items: + + % if item.status == "purchased": + + + + + % elif item.status == "refunded": + + + + + % endif + % endfor + + + +
    QtyDescriptionUnit PricePriceCurrency
    ${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}
    ${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}
    ${_("Total Amount")}
    ${"{0:0.2f}".format(order.total_cost)}
    + % if any_refunds: +

    + ${_("Note: items with strikethough like ")}this${_(" have been refunded.")} +

    + % endif + +

    ${_("Billed To:")}

    +

    + ${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}
    + ${order.bill_to_first} ${order.bill_to_last}
    + ${order.bill_to_street1}
    + ${order.bill_to_street2}
    + ${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}
    + ${order.bill_to_country.upper()}
    +

    + +
    From a4f5f4e42ff623677dbd613c929f591b4fe5e2fb Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 13 Aug 2013 11:41:05 -0700 Subject: [PATCH 014/185] about page changes, refactor processor reply handling --- common/lib/xmodule/xmodule/course_module.py | 1 + common/static/js/capa/spec/jsinput_spec.js | 70 ------------------ lms/djangoapps/shoppingcart/models.py | 53 ++++++++++---- .../shoppingcart/processors/CyberSource.py | 52 +++++++++++++- .../shoppingcart/processors/__init__.py | 32 ++++++--- .../shoppingcart/processors/exceptions.py | 2 +- lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 71 +++++++++---------- lms/templates/courseware/course_about.html | 2 +- lms/templates/shoppingcart/list.html | 6 +- lms/templates/shoppingcart/receipt.html | 6 +- 11 files changed, 160 insertions(+), 138 deletions(-) delete mode 100644 common/static/js/capa/spec/jsinput_spec.js diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 57b13c10b3..caf731a392 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -338,6 +338,7 @@ class CourseFields(object): show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True) enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", scope=Scope.settings) + enrollment_cost = Dict(scope=Scope.settings, default={'currency':'usd', 'cost':0}) # An extra property is used rather than the wiki_slug/number because # there are courses that change the number for different runs. This allows diff --git a/common/static/js/capa/spec/jsinput_spec.js b/common/static/js/capa/spec/jsinput_spec.js deleted file mode 100644 index a4a4f6e57d..0000000000 --- a/common/static/js/capa/spec/jsinput_spec.js +++ /dev/null @@ -1,70 +0,0 @@ -xdescribe("A jsinput has:", function () { - - beforeEach(function () { - $('#fixture').remove(); - $.ajax({ - async: false, - url: 'mainfixture.html', - success: function(data) { - $('body').append($(data)); - } - }); - }); - - - - describe("The jsinput constructor", function(){ - - var iframe1 = $(document).find('iframe')[0]; - - var testJsElem = jsinputConstructor({ - id: 1, - elem: iframe1, - passive: false - }); - - it("Returns an object", function(){ - expect(typeof(testJsElem)).toEqual('object'); - }); - - it("Adds the object to the jsinput array", function() { - expect(jsinput.exists(1)).toBe(true); - }); - - describe("The returned object", function() { - - it("Has a public 'update' method", function(){ - expect(testJsElem.update).toBeDefined(); - }); - - it("Returns an 'update' that is idempotent", function(){ - var orig = testJsElem.update(); - for (var i = 0; i++; i < 5) { - expect(testJsElem.update()).toEqual(orig); - } - }); - - it("Changes the parent's inputfield", function() { - testJsElem.update(); - - }); - }); - }); - - - describe("The walkDOM functions", function() { - - walkDOM(); - - it("Creates (at least) one object per iframe", function() { - jsinput.arr.length >= 2; - }); - - it("Does not create multiple objects with the same id", function() { - while (jsinput.arr.length > 0) { - var elem = jsinput.arr.pop(); - expect(jsinput.exists(elem.id)).toBe(false); - } - }); - }); -}) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 052ecfb888..acc0545ab7 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -123,11 +123,13 @@ class OrderItem(models.Model): Unfortunately the QuerySet used determines the class to be OrderItem, and not its most specific subclasses. That means this parent class implementation of purchased_callback needs to act as - a dispatcher to call the callback the proper subclasses, and as such it needs to know about all subclasses. - So please add + a dispatcher to call the callback the proper subclasses, and as such it needs to know about all + possible subclasses. + So keep ORDER_ITEM_SUBTYPES up-to-date """ - for classname, lc_classname in ORDER_ITEM_SUBTYPES: + for cls, lc_classname in ORDER_ITEM_SUBTYPES.iteritems(): try: + #Uses https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance to test subclass sub_instance = getattr(self,lc_classname) sub_instance.purchased_callback() except (ObjectDoesNotExist, AttributeError): @@ -135,13 +137,18 @@ class OrderItem(models.Model): .format(lc_classname)) pass -# Each entry is a tuple of ('ModelName', 'lower_case_model_name') -# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for -# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem -ORDER_ITEM_SUBTYPES = [ - ('PaidCourseRegistration', 'paidcourseregistration') -] - + def is_of_subtype(self, cls): + """ + Checks if self is also a type of cls, in addition to being an OrderItem + Uses https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance to test for subclass + """ + if cls not in ORDER_ITEM_SUBTYPES: + return False + try: + getattr(self, ORDER_ITEM_SUBTYPES[cls]) + return True + except (ObjectDoesNotExist, AttributeError): + return False class PaidCourseRegistration(OrderItem): @@ -151,7 +158,16 @@ class PaidCourseRegistration(OrderItem): course_id = models.CharField(max_length=128, db_index=True) @classmethod - def add_to_order(cls, order, course_id, cost, currency='usd'): + def part_of_order(cls, order, course_id): + """ + Is the course defined by course_id in the order? + """ + return course_id in [item.paidcourseregistration.course_id + for item in order.orderitem_set.all() + if item.is_of_subtype(PaidCourseRegistration)] + + @classmethod + def add_to_order(cls, order, course_id, cost=None, currency=None): """ A standardized way to create these objects, with sensible defaults filled in. Will update the cost if called on an order that already carries the course. @@ -164,6 +180,10 @@ class PaidCourseRegistration(OrderItem): item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status item.qty = 1 + if cost is None: + cost = course.enrollment_cost['cost'] + if currency is None: + currency = course.enrollment_cost['currency'] item.unit_cost = cost item.line_cost = cost item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) @@ -182,9 +202,6 @@ class PaidCourseRegistration(OrderItem): # throw errors if it doesn't # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for # whatever reason. - # Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency - # with rest of codebase. - CourseEnrollmentAllowed.objects.get_or_create(email=self.user.email, course_id=self.course_id, auto_enroll=True) CourseEnrollment.objects.get_or_create(user=self.user, course_id=self.course_id) log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) @@ -193,3 +210,11 @@ class PaidCourseRegistration(OrderItem): tags=["org:{0}".format(org), "course:{0}".format(course_num), "run:{0}".format(run)]) + + +# Each entry is a dictionary of ModelName: 'lower_case_model_name' +# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for +# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem +ORDER_ITEM_SUBTYPES = { + PaidCourseRegistration: 'paidcourseregistration', +} \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 17e1511ac6..75ad754237 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -13,7 +13,7 @@ from django.conf import settings from django.utils.translation import ugettext as _ from mitxmako.shortcuts import render_to_string from shoppingcart.models import Order -from .exceptions import CCProcessorDataException, CCProcessorWrongAmountException +from .exceptions import CCProcessorException, CCProcessorDataException, CCProcessorWrongAmountException shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') @@ -21,6 +21,42 @@ serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') +def process_postpay_callback(request): + """ + The top level call to this module, basically + This function is handed the callback request after the customer has entered the CC info and clicked "buy" + on the external Hosted Order Page. + It is expected to verify the callback and determine if the payment was successful. + It returns {'success':bool, 'order':Order, 'error_html':str} + If successful this function must have the side effect of marking the order purchased and calling the + purchased_callbacks of the cart items. + If unsuccessful this function should not have those side effects but should try to figure out why and + return a helpful-enough error message in error_html. + """ + params = request.POST.dict() + if verify_signatures(params): + try: + result = payment_accepted(params) + if result['accepted']: + # SUCCESS CASE first, rest are some sort of oddity + record_purchase(params, result['order']) + return {'success': True, + 'order': result['order'], + 'error_html': ''} + else: + return {'success': False, + 'order': result['order'], + 'error_html': get_processor_error_html(params)} + except CCProcessorException as e: + return {'success': False, + 'order': None, #due to exception we may not have the order + 'error_html': get_exception_html(params, e)} + else: + return {'success': False, + 'order': None, + 'error_html': get_signature_error_html(params)} + + def hash(value): """ Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page @@ -48,7 +84,7 @@ def sign(params): return params -def verify(params): +def verify_signatures(params): """ Verify the signatures accompanying the POST back from Cybersource Hosted Order Page """ @@ -161,6 +197,18 @@ def record_purchase(params, order): processor_reply_dump=json.dumps(params) ) +def get_processor_error_html(params): + """Have to parse through the error codes for all the other cases""" + return "

    ERROR!

    " + +def get_exception_html(params, exp): + """Return error HTML associated with exception""" + return "

    EXCEPTION!

    " + +def get_signature_error_html(params): + """Return error HTML associated with signature failure""" + return "

    EXCEPTION!

    " + CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") CARDTYPE_MAP.update( diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index 520c353535..45a6e3114d 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -8,8 +8,32 @@ module = __import__('shoppingcart.processors.' + processor_name, 'render_purchase_form_html' 'payment_accepted', 'record_purchase', + 'process_postpay_callback', ]) +def render_purchase_form_html(*args, **kwargs): + """ + The top level call to this module to begin the purchase. + Given a shopping cart, + Renders the HTML form for display on user's browser, which POSTS to Hosted Processors + Returns the HTML as a string + """ + return module.render_purchase_form_html(*args, **kwargs) + +def process_postpay_callback(*args, **kwargs): + """ + The top level call to this module after the purchase. + This function is handed the callback request after the customer has entered the CC info and clicked "buy" + on the external payment page. + It is expected to verify the callback and determine if the payment was successful. + It returns {'success':bool, 'order':Order, 'error_html':str} + If successful this function must have the side effect of marking the order purchased and calling the + purchased_callbacks of the cart items. + If unsuccessful this function should not have those side effects but should try to figure out why and + return a helpful-enough error message in error_html. + """ + return module.process_postpay_callback(*args, **kwargs) + def sign(*args, **kwargs): """ Given a dict (or OrderedDict) of parameters to send to the @@ -30,14 +54,6 @@ def verify(*args, **kwargs): """ return module.sign(*args, **kwargs) -def render_purchase_form_html(*args, **kwargs): - """ - Given a shopping cart, - Renders the HTML form for display on user's browser, which POSTS to Hosted Processors - Returns the HTML as a string - """ - return module.render_purchase_form_html(*args, **kwargs) - def payment_accepted(*args, **kwargs): """ Given params returned by the CC processor, check that processor has accepted the payment diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py index bc132a3d54..e863688133 100644 --- a/lms/djangoapps/shoppingcart/processors/exceptions.py +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -7,5 +7,5 @@ class CCProcessorException(PaymentException): class CCProcessorDataException(CCProcessorException): pass -class CCProcessorWrongAmountException(PaymentException): +class CCProcessorWrongAmountException(CCProcessorException): pass \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 58e51f0b40..892c66d5bb 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -6,7 +6,6 @@ urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), - url(r'^purchased/$', 'purchased'), - url(r'^postpay_accept_callback/$', 'postpay_accept_callback'), + url(r'^postpay_callback/$', 'postpay_callback'), #Both the ~accept and ~reject callback pages are handled here url(r'^receipt/(?P[0-9]*)/$', 'show_receipt'), ) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index f5540aafbb..87df7eaf1b 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,13 +1,14 @@ import logging - -from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.http import HttpResponse, HttpResponseRedirect, HttpResponseNotFound, HttpResponseForbidden, Http404 +from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required +from student.models import CourseEnrollment +from xmodule.modulestore.exceptions import ItemNotFoundError from mitxmako.shortcuts import render_to_response from .models import * -from .processors import verify, payment_accepted, render_purchase_form_html, record_purchase -from .processors.exceptions import CCProcessorDataException, CCProcessorWrongAmountException +from .processors import process_postpay_callback, render_purchase_form_html log = logging.getLogger("shoppingcart") @@ -16,20 +17,22 @@ def test(request, course_id): item1.purchased_callback(request.user.id) return HttpResponse('OK') -@login_required -def purchased(request): - #verify() -- signatures, total cost match up, etc. Need error handling code ( - # If verify fails probaly need to display a contact email/number) - cart = Order.get_cart_for_user(request.user) - cart.purchase() - return HttpResponseRedirect('/') -@login_required def add_course_to_cart(request, course_id): + if not request.user.is_authenticated(): + return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart')) cart = Order.get_cart_for_user(request.user) - # TODO: Catch 500 here for course that does not exist, period - PaidCourseRegistration.add_to_order(cart, course_id, 200) - return HttpResponse("Added") + if PaidCourseRegistration.part_of_order(cart, course_id): + return HttpResponseNotFound(_('The course {0} is already in your cart.'.format(course_id))) + if CourseEnrollment.objects.filter(user=request.user, course_id=course_id).exists(): + return HttpResponseNotFound(_('You are already registered in course {0}.'.format(course_id))) + try: + PaidCourseRegistration.add_to_order(cart, course_id) + except ItemNotFoundError: + return HttpResponseNotFound(_('The course you requested does not exist.')) + if request.method == 'GET': + return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) + return HttpResponse(_("Course added to cart.")) @login_required def show_cart(request): @@ -62,31 +65,23 @@ def remove_item(request): return HttpResponse('OK') @csrf_exempt -def postpay_accept_callback(request): +def postpay_callback(request): """ - Receives the POST-back from processor and performs the validation and displays a receipt - and does some other stuff - - HANDLES THE ACCEPT AND REVIEW CASES + Receives the POST-back from processor. + Mainly this calls the processor-specific code to check if the payment was accepted, and to record the order + if it was, and to generate an error page. + If successful this function should have the side effect of changing the "cart" into a full "order" in the DB. + The cart can then render a success page which links to receipt pages. + If unsuccessful the order will be left untouched and HTML messages giving more detailed error info will be + returned. """ - # TODO: Templates and logic for all error cases and the REVIEW CASE - params = request.POST.dict() - if verify(params): - try: - result = payment_accepted(params) - if result['accepted']: - # ACCEPTED CASE first - record_purchase(params, result['order']) - #render_receipt - return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) - else: - return HttpResponse("CC Processor has not accepted the payment.") - except CCProcessorWrongAmountException: - return HttpResponse("Charged the wrong amount, contact our user support") - except CCProcessorDataException: - return HttpResponse("Exception: the processor returned invalid data") + result = process_postpay_callback(request) + if result['success']: + return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) else: - return HttpResponse("There has been a communication problem blah blah. Not Validated") + return render_to_response('shoppingcart.processor_error.html', {'order':result['order'], + 'error_html': result['error_html']}) + def show_receipt(request, ordernum): """ @@ -107,7 +102,7 @@ def show_receipt(request, ordernum): 'order_items': order_items, 'any_refunds': any_refunds}) -def show_orders(request): +#def show_orders(request): """ Displays all orders of a user """ diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index e4a453133d..4d22e6959c 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -59,7 +59,6 @@ %endif - })(this) @@ -93,6 +92,7 @@ ${_("View Courseware")} %endif + %else: ${_("Register for {course.display_number_with_default}").format(course=course) | h} diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 677077ba2d..0754cac311 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -25,7 +25,7 @@ - + ${form_html} % else:

    ${_("You have selected no items for purchase.")}

    @@ -44,6 +44,10 @@ location.reload(true); }); }); + + $('#back_input').click(function(){ + history.back(); + }); }); diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html index 01de3b3f83..0386b6b353 100644 --- a/lms/templates/shoppingcart/receipt.html +++ b/lms/templates/shoppingcart/receipt.html @@ -6,7 +6,11 @@ <%block name="title">${_("Receipt for Order")} ${order.id} - +% if notification is not UNDEFINED: +
    + ${notification} +
    +% endif

    ${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}

    From d719f14a52b681569802774519742afaf47e7424 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 9 Aug 2013 12:57:34 -0400 Subject: [PATCH 015/185] Add in new VerifiedCertificate order item --- .../0003_auto__add_verifiedcertificate.py | 111 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 36 +++++- lms/djangoapps/shoppingcart/tests.py | 31 +++-- lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 8 ++ 5 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py new file mode 100644 index 0000000000..25c0d46948 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py @@ -0,0 +1,111 @@ +# -*- 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 'VerifiedCertificate' + db.create_table('shoppingcart_verifiedcertificate', ( + ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + ('course_enrollment', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['student.CourseEnrollment'])), + )) + db.send_create_signal('shoppingcart', ['VerifiedCertificate']) + + + def backwards(self, orm): + # Deleting model 'VerifiedCertificate' + db.delete_table('shoppingcart_verifiedcertificate') + + + 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'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.verifiedcertificate': { + 'Meta': {'object_name': 'VerifiedCertificate', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index acc0545ab7..42e6bc842a 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -217,4 +217,38 @@ class PaidCourseRegistration(OrderItem): # PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem ORDER_ITEM_SUBTYPES = { PaidCourseRegistration: 'paidcourseregistration', -} \ No newline at end of file + VerifiedCertificate: 'verifiedcertificate', +} + + +class VerifiedCertificate(OrderItem): + """ + This is an inventory item for purchasing verified certificates + """ + course_id = models.CharField(max_length=128, db_index=True) + course_enrollment = models.ForeignKey(CourseEnrollment) + + @classmethod + def add_to_order(cls, order, course_id, course_enrollment, cost, currency='usd'): + """ + Add a VerifiedCertificate item to an order + """ + # TODO: error checking + item, _created = cls.objects.get_or_create( + order=order, + user=order.user, + course_id=course_id, + course_enrollment=course_enrollment + ) + item.status = order.status + item.qty = 1 + item.unit_cost = cost + item.line_cost = cost + item.line_desc = "Verified Certificate for Course {0}".format(course_id) + item.currency = currency + item.save() + return item + + def purchased_callback(self): + # TODO: add code around putting student in the verified track + pass diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 501deb776c..55b5ae0141 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -1,16 +1,27 @@ """ -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. +Tests for the Shopping Cart """ +from factory import DjangoModelFactory from django.test import TestCase +from shoppingcart import models +from student.tests.factories import UserFactory -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) +class OrderFactory(DjangoModelFactory): + FACTORY_FOR = models.Order + + +class OrderItem(DjangoModelFactory): + FACTORY_FOR = models.OrderItem + + +class OrderTest(TestCase): + def setUp(self): + self.user = UserFactory.create() + self.cart = OrderFactory.create(user=self.user, status='cart') + + def test_total_cost(self): + # add items to the order + for _ in xrange(5): + pass diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 892c66d5bb..1ec4f9402e 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -4,8 +4,9 @@ urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^$','show_cart'), url(r'^(?P[^/]+/[^/]+/[^/]+)/$','test'), url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), + url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', 'register_for_verified_cert'), url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^postpay_callback/$', 'postpay_callback'), #Both the ~accept and ~reject callback pages are handled here url(r'^receipt/(?P[0-9]*)/$', 'show_receipt'), -) \ No newline at end of file +) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 87df7eaf1b..718893069e 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -34,6 +34,14 @@ def add_course_to_cart(request, course_id): return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) return HttpResponse(_("Course added to cart.")) + +@login_required +def register_for_verified_cert(request, course_id): + cart = Order.get_cart_for_user(request.user) + enrollment, _completed = CourseEnrollment.objects.get_or_create(user=request.user, course_id=course_id) + VerifiedCertificate.add_to_order(cart, course_id, enrollment, 25) + return HttpResponse("Added") + @login_required def show_cart(request): cart = Order.get_cart_for_user(request.user) From cff5491f8cac69fd5558acdeb9c0eeef91487d0e Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 12 Aug 2013 12:18:26 -0400 Subject: [PATCH 016/185] Pull CyberSource values from environment variables when in a dev environment. --- lms/djangoapps/shoppingcart/models.py | 4 +++- lms/djangoapps/shoppingcart/views.py | 3 +-- lms/envs/dev.py | 7 +++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 42e6bc842a..66e6dfca2e 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -229,11 +229,13 @@ class VerifiedCertificate(OrderItem): course_enrollment = models.ForeignKey(CourseEnrollment) @classmethod - def add_to_order(cls, order, course_id, course_enrollment, cost, currency='usd'): + def add_to_order(cls, order, course_id, cost, currency='usd'): """ Add a VerifiedCertificate item to an order """ + # TODO: add the basic enrollment # TODO: error checking + course_enrollment = CourseEnrollment.create_enrollment(order.user, course_id, mode="verified") item, _created = cls.objects.get_or_create( order=order, user=order.user, diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 718893069e..f6ca5d0837 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -38,8 +38,7 @@ def add_course_to_cart(request, course_id): @login_required def register_for_verified_cert(request, course_id): cart = Order.get_cart_for_user(request.user) - enrollment, _completed = CourseEnrollment.objects.get_or_create(user=request.user, course_id=course_id) - VerifiedCertificate.add_to_order(cart, course_id, enrollment, 25) + VerifiedCertificate.add_to_order(cart, course_id, 30) return HttpResponse("Added") @login_required diff --git a/lms/envs/dev.py b/lms/envs/dev.py index d47c7bf82d..9150adb3a3 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -258,6 +258,13 @@ SEGMENT_IO_LMS_KEY = os.environ.get('SEGMENT_IO_LMS_KEY') if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = True +###################### Payment ##############################3 + +CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = os.environ.get('CYBERSOURCE_SHARED_SECRET', '') +CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = os.environ.get('CYBERSOURCE_MERCHANT_ID', '') +CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = os.environ.get('CYBERSOURCE_SERIAL_NUMBER', '') +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_PURCHASE_ENDPOINT', '') + ########################## USER API ######################## EDX_API_KEY = None From d6e777bc1b785053e8a5dbe4a2dcedbc836698b9 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 10:12:06 -0400 Subject: [PATCH 017/185] Remove enrollment_cost from course_module --- common/lib/xmodule/xmodule/course_module.py | 1 - lms/djangoapps/shoppingcart/models.py | 4 ---- 2 files changed, 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index caf731a392..57b13c10b3 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -338,7 +338,6 @@ class CourseFields(object): show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True) enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", scope=Scope.settings) - enrollment_cost = Dict(scope=Scope.settings, default={'currency':'usd', 'cost':0}) # An extra property is used rather than the wiki_slug/number because # there are courses that change the number for different runs. This allows diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 66e6dfca2e..eb4e9f578d 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -180,10 +180,6 @@ class PaidCourseRegistration(OrderItem): item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status item.qty = 1 - if cost is None: - cost = course.enrollment_cost['cost'] - if currency is None: - currency = course.enrollment_cost['currency'] item.unit_cost = cost item.line_cost = cost item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) From 77ee243e77af87d66fd07f1e6efd18958c9eebf1 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 13:36:13 -0400 Subject: [PATCH 018/185] Some cleanup fixes to get verified certs working. --- lms/djangoapps/shoppingcart/models.py | 32 ++++++++++++++++----------- lms/djangoapps/shoppingcart/views.py | 9 ++------ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index eb4e9f578d..54e7a33889 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -61,6 +61,12 @@ class Order(models.Model): else: return items[0].currency + def clear(self): + """ + Clear out all the items in the cart + """ + self.orderitem_set.all().delete() + def purchase(self, first='', last='', street1='', street2='', city='', state='', postalcode='', country='', ccnum='', cardtype='', processor_reply_dump=''): """ @@ -208,15 +214,6 @@ class PaidCourseRegistration(OrderItem): "run:{0}".format(run)]) -# Each entry is a dictionary of ModelName: 'lower_case_model_name' -# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for -# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem -ORDER_ITEM_SUBTYPES = { - PaidCourseRegistration: 'paidcourseregistration', - VerifiedCertificate: 'verifiedcertificate', -} - - class VerifiedCertificate(OrderItem): """ This is an inventory item for purchasing verified certificates @@ -229,8 +226,6 @@ class VerifiedCertificate(OrderItem): """ Add a VerifiedCertificate item to an order """ - # TODO: add the basic enrollment - # TODO: error checking course_enrollment = CourseEnrollment.create_enrollment(order.user, course_id, mode="verified") item, _created = cls.objects.get_or_create( order=order, @@ -248,5 +243,16 @@ class VerifiedCertificate(OrderItem): return item def purchased_callback(self): - # TODO: add code around putting student in the verified track - pass + """ + When purchase goes through, activate the course enrollment + """ + self.course_enrollment.activate() + + +# Each entry is a dictionary of ModelName: 'lower_case_model_name' +# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for +# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem +ORDER_ITEM_SUBTYPES = { + PaidCourseRegistration: 'paidcourseregistration', + VerifiedCertificate: 'verifiedcertificate', +} diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index f6ca5d0837..91dff59aed 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -57,7 +57,7 @@ def show_cart(request): @login_required def clear_cart(request): cart = Order.get_cart_for_user(request.user) - cart.orderitem_set.all().delete() + cart.clear() return HttpResponse('Cleared') @login_required @@ -89,7 +89,7 @@ def postpay_callback(request): return render_to_response('shoppingcart.processor_error.html', {'order':result['order'], 'error_html': result['error_html']}) - +@login_required def show_receipt(request, ordernum): """ Displays a receipt for a particular order. @@ -108,8 +108,3 @@ def show_receipt(request, ordernum): return render_to_response('shoppingcart/receipt.html', {'order': order, 'order_items': order_items, 'any_refunds': any_refunds}) - -#def show_orders(request): - """ - Displays all orders of a user - """ From 88f54fff202b756447d9b999c60eaa5439c5b924 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 14:18:46 -0400 Subject: [PATCH 019/185] Put shopping cart views behind flags --- lms/djangoapps/shoppingcart/urls.py | 25 ++++++++++++++++++------- lms/envs/common.py | 3 +++ lms/envs/dev.py | 1 + 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 1ec4f9402e..7893d29c20 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -1,12 +1,23 @@ from django.conf.urls import patterns, include, url +from django.conf import settings urlpatterns = patterns('shoppingcart.views', # nopep8 - url(r'^$','show_cart'), - url(r'^(?P[^/]+/[^/]+/[^/]+)/$','test'), - url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), - url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', 'register_for_verified_cert'), - url(r'^clear/$','clear_cart'), - url(r'^remove_item/$', 'remove_item'), - url(r'^postpay_callback/$', 'postpay_callback'), #Both the ~accept and ~reject callback pages are handled here + url(r'^postpay_callback/$', 'postpay_callback'), # Both the ~accept and ~reject callback pages are handled here url(r'^receipt/(?P[0-9]*)/$', 'show_receipt'), ) +if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: + urlpatterns += patterns( + 'shoppingcart.views', + url(r'^$', 'show_cart'), + url(r'^clear/$', 'clear_cart'), + url(r'^remove_item/$', 'remove_item'), + ) + +if settings.DEBUG: + urlpatterns += patterns( + 'shoppingcart.views', + url(r'^(?P[^/]+/[^/]+/[^/]+)/$', 'test'), + url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart'), + url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', + 'register_for_verified_cert'), + ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 7e4d23f065..c5b174b077 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -154,6 +154,9 @@ MITX_FEATURES = { # Toggle to enable chat availability (configured on a per-course # basis in Studio) 'ENABLE_CHAT': False, + + # Toggle the availability of the shopping cart page + 'ENABLE_SHOPPING_CART': False } # Used for A/B testing diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 9150adb3a3..cc78dcc6ca 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -30,6 +30,7 @@ MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +MITX_FEATURES['ENABLE_SHOPPING_CART'] = True FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com" From 38ba856ddc33ee170292f49d3de5c1112cf2a334 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 15:22:17 -0400 Subject: [PATCH 020/185] Start building tests --- lms/djangoapps/shoppingcart/tests.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 55b5ae0141..521b9e594e 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -4,24 +4,32 @@ Tests for the Shopping Cart from factory import DjangoModelFactory from django.test import TestCase -from shoppingcart import models +from shoppingcart.models import Order, VerifiedCertificate from student.tests.factories import UserFactory class OrderFactory(DjangoModelFactory): - FACTORY_FOR = models.Order + FACTORY_FOR = Order -class OrderItem(DjangoModelFactory): - FACTORY_FOR = models.OrderItem +class VerifiedCertificateFactory(DjangoModelFactory): + FACTORY_FOR = VerifiedCertificate class OrderTest(TestCase): def setUp(self): self.user = UserFactory.create() self.cart = OrderFactory.create(user=self.user, status='cart') + self.course_id = "test/course" + + def test_add_item_to_cart(self): + pass def test_total_cost(self): # add items to the order - for _ in xrange(5): - pass + cost = 30 + iterations = 5 + for _ in xrange(iterations): + VerifiedCertificate.add_to_order(self.cart, self.user, self.course_id, cost) + self.assertEquals(self.cart.total_cost, cost * iterations) + From c5f353ec05c02e0b38aa4eecca92a985538dda2d Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 20 Aug 2013 15:28:19 -0400 Subject: [PATCH 021/185] Major cleanup work on ShoppingCart models * Make currency a property of the Order (for validation purposes) * Remove null=True from Char fields * Use InheritanceManager for subclassing OrderItem * Change VerifiedCertificate to better handle some new use cases * Cleaned out old migrations * Tests! --- .../shoppingcart/migrations/0001_initial.py | 64 +++++- ...d_field_order_bill_to_first__add_field_.py | 183 ------------------ ...003_auto__add_field_order_bill_to_state.py | 96 --------- .../0003_auto__add_verifiedcertificate.py | 111 ----------- lms/djangoapps/shoppingcart/models.py | 166 ++++++++-------- lms/djangoapps/shoppingcart/tests.py | 88 +++++++-- lms/djangoapps/shoppingcart/views.py | 2 +- lms/envs/common.py | 6 +- requirements/edx/base.txt | 1 + 9 files changed, 219 insertions(+), 498 deletions(-) delete mode 100644 lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py delete mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py delete mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py diff --git a/lms/djangoapps/shoppingcart/migrations/0001_initial.py b/lms/djangoapps/shoppingcart/migrations/0001_initial.py index 779eccc94d..ea6a250f77 100644 --- a/lms/djangoapps/shoppingcart/migrations/0001_initial.py +++ b/lms/djangoapps/shoppingcart/migrations/0001_initial.py @@ -12,9 +12,20 @@ class Migration(SchemaMigration): db.create_table('shoppingcart_order', ( ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('currency', self.gf('django.db.models.fields.CharField')(default='usd', max_length=8)), ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), - ('nonce', self.gf('django.db.models.fields.CharField')(max_length=128)), ('purchase_time', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), + ('bill_to_first', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_last', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_street1', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('bill_to_street2', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('bill_to_city', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_state', self.gf('django.db.models.fields.CharField')(max_length=8, blank=True)), + ('bill_to_postalcode', self.gf('django.db.models.fields.CharField')(max_length=16, blank=True)), + ('bill_to_country', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_ccnum', self.gf('django.db.models.fields.CharField')(max_length=8, blank=True)), + ('bill_to_cardtype', self.gf('django.db.models.fields.CharField')(max_length=32, blank=True)), + ('processor_reply_dump', self.gf('django.db.models.fields.TextField')(blank=True)), )) db.send_create_signal('shoppingcart', ['Order']) @@ -25,9 +36,10 @@ class Migration(SchemaMigration): ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), ('qty', self.gf('django.db.models.fields.IntegerField')(default=1)), - ('unit_cost', self.gf('django.db.models.fields.FloatField')(default=0.0)), - ('line_cost', self.gf('django.db.models.fields.FloatField')(default=0.0)), + ('unit_cost', self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2)), + ('line_cost', self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2)), ('line_desc', self.gf('django.db.models.fields.CharField')(default='Misc. Item', max_length=1024)), + ('currency', self.gf('django.db.models.fields.CharField')(default='usd', max_length=8)), )) db.send_create_signal('shoppingcart', ['OrderItem']) @@ -38,6 +50,15 @@ class Migration(SchemaMigration): )) db.send_create_signal('shoppingcart', ['PaidCourseRegistration']) + # Adding model 'CertificateItem' + db.create_table('shoppingcart_certificateitem', ( + ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + ('course_enrollment', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['student.CourseEnrollment'])), + ('mode', self.gf('django.db.models.fields.SlugField')(max_length=50)), + )) + db.send_create_signal('shoppingcart', ['CertificateItem']) + def backwards(self, orm): # Deleting model 'Order' @@ -49,6 +70,9 @@ class Migration(SchemaMigration): # Deleting model 'PaidCourseRegistration' db.delete_table('shoppingcart_paidcourseregistration') + # Deleting model 'CertificateItem' + db.delete_table('shoppingcart_certificateitem') + models = { 'auth.group': { @@ -87,29 +111,57 @@ class Migration(SchemaMigration): 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, 'shoppingcart.order': { 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'nonce': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) }, 'shoppingcart.orderitem': { 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) }, 'shoppingcart.paidcourseregistration': { 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) } } diff --git a/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py b/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py deleted file mode 100644 index 940116f7b8..0000000000 --- a/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py +++ /dev/null @@ -1,183 +0,0 @@ -# -*- 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): - # Deleting field 'Order.nonce' - db.delete_column('shoppingcart_order', 'nonce') - - # Adding field 'Order.bill_to_first' - db.add_column('shoppingcart_order', 'bill_to_first', - self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_last' - db.add_column('shoppingcart_order', 'bill_to_last', - self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_street1' - db.add_column('shoppingcart_order', 'bill_to_street1', - self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_street2' - db.add_column('shoppingcart_order', 'bill_to_street2', - self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_city' - db.add_column('shoppingcart_order', 'bill_to_city', - self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_postalcode' - db.add_column('shoppingcart_order', 'bill_to_postalcode', - self.gf('django.db.models.fields.CharField')(max_length=16, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_country' - db.add_column('shoppingcart_order', 'bill_to_country', - self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_ccnum' - db.add_column('shoppingcart_order', 'bill_to_ccnum', - self.gf('django.db.models.fields.CharField')(max_length=8, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_cardtype' - db.add_column('shoppingcart_order', 'bill_to_cardtype', - self.gf('django.db.models.fields.CharField')(max_length=32, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.processor_reply_dump' - db.add_column('shoppingcart_order', 'processor_reply_dump', - self.gf('django.db.models.fields.TextField')(null=True, blank=True), - keep_default=False) - - # Adding field 'OrderItem.currency' - db.add_column('shoppingcart_orderitem', 'currency', - self.gf('django.db.models.fields.CharField')(default='usd', max_length=8), - keep_default=False) - - - def backwards(self, orm): - # Adding field 'Order.nonce' - db.add_column('shoppingcart_order', 'nonce', - self.gf('django.db.models.fields.CharField')(default='defaultNonce', max_length=128), - keep_default=False) - - # Deleting field 'Order.bill_to_first' - db.delete_column('shoppingcart_order', 'bill_to_first') - - # Deleting field 'Order.bill_to_last' - db.delete_column('shoppingcart_order', 'bill_to_last') - - # Deleting field 'Order.bill_to_street1' - db.delete_column('shoppingcart_order', 'bill_to_street1') - - # Deleting field 'Order.bill_to_street2' - db.delete_column('shoppingcart_order', 'bill_to_street2') - - # Deleting field 'Order.bill_to_city' - db.delete_column('shoppingcart_order', 'bill_to_city') - - # Deleting field 'Order.bill_to_postalcode' - db.delete_column('shoppingcart_order', 'bill_to_postalcode') - - # Deleting field 'Order.bill_to_country' - db.delete_column('shoppingcart_order', 'bill_to_country') - - # Deleting field 'Order.bill_to_ccnum' - db.delete_column('shoppingcart_order', 'bill_to_ccnum') - - # Deleting field 'Order.bill_to_cardtype' - db.delete_column('shoppingcart_order', 'bill_to_cardtype') - - # Deleting field 'Order.processor_reply_dump' - db.delete_column('shoppingcart_order', 'processor_reply_dump') - - # Deleting field 'OrderItem.currency' - db.delete_column('shoppingcart_orderitem', 'currency') - - - 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'}) - }, - 'shoppingcart.order': { - 'Meta': {'object_name': 'Order'}, - 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), - 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), - 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), - 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.orderitem': { - 'Meta': {'object_name': 'OrderItem'}, - 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), - 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), - 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.paidcourseregistration': { - 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - } - } - - complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py deleted file mode 100644 index 85923794b6..0000000000 --- a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py +++ /dev/null @@ -1,96 +0,0 @@ -# -*- 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 field 'Order.bill_to_state' - db.add_column('shoppingcart_order', 'bill_to_state', - self.gf('django.db.models.fields.CharField')(max_length=8, null=True, blank=True), - keep_default=False) - - - def backwards(self, orm): - # Deleting field 'Order.bill_to_state' - db.delete_column('shoppingcart_order', 'bill_to_state') - - - 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'}) - }, - 'shoppingcart.order': { - 'Meta': {'object_name': 'Order'}, - 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), - 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), - 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), - 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), - 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.orderitem': { - 'Meta': {'object_name': 'OrderItem'}, - 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), - 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), - 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.paidcourseregistration': { - 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - } - } - - complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py deleted file mode 100644 index 25c0d46948..0000000000 --- a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- 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 'VerifiedCertificate' - db.create_table('shoppingcart_verifiedcertificate', ( - ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), - ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), - ('course_enrollment', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['student.CourseEnrollment'])), - )) - db.send_create_signal('shoppingcart', ['VerifiedCertificate']) - - - def backwards(self, orm): - # Deleting model 'VerifiedCertificate' - db.delete_table('shoppingcart_verifiedcertificate') - - - 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'}) - }, - 'shoppingcart.order': { - 'Meta': {'object_name': 'Order'}, - 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), - 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), - 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), - 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.orderitem': { - 'Meta': {'object_name': 'OrderItem'}, - 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), - 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), - 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.paidcourseregistration': { - 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - }, - 'shoppingcart.verifiedcertificate': { - 'Meta': {'object_name': 'VerifiedCertificate', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - }, - 'student.courseenrollment': { - 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - } - } - - complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 54e7a33889..3a4039c9e1 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1,21 +1,27 @@ import pytz import logging -from datetime import datetime +from datetime import datetime from django.db import models from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User -from courseware.courses import course_image_url, get_course_about_section +from django.utils.translation import ugettext as _ +from model_utils.managers import InheritanceManager +from courseware.courses import get_course_about_section from student.views import course_from_id -from student.models import CourseEnrollmentAllowed, CourseEnrollment +from student.models import CourseEnrollment from statsd import statsd log = logging.getLogger("shoppingcart") +class InvalidCartItem(Exception): + pass + ORDER_STATUSES = ( ('cart', 'cart'), ('purchased', 'purchased'), ('refunded', 'refunded'), # Not used for now ) + class Order(models.Model): """ This is the model for an order. Before purchase, an Order and its related OrderItems are used @@ -23,43 +29,40 @@ class Order(models.Model): FOR ANY USER, THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart'. """ user = models.ForeignKey(User, db_index=True) + currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) purchase_time = models.DateTimeField(null=True, blank=True) # Now we store data needed to generate a reasonable receipt # These fields only make sense after the purchase - bill_to_first = models.CharField(max_length=64, null=True, blank=True) - bill_to_last = models.CharField(max_length=64, null=True, blank=True) - bill_to_street1 = models.CharField(max_length=128, null=True, blank=True) - bill_to_street2 = models.CharField(max_length=128, null=True, blank=True) - bill_to_city = models.CharField(max_length=64, null=True, blank=True) - bill_to_state = models.CharField(max_length=8, null=True, blank=True) - bill_to_postalcode = models.CharField(max_length=16, null=True, blank=True) - bill_to_country = models.CharField(max_length=64, null=True, blank=True) - bill_to_ccnum = models.CharField(max_length=8, null=True, blank=True) # last 4 digits - bill_to_cardtype = models.CharField(max_length=32, null=True, blank=True) + bill_to_first = models.CharField(max_length=64, blank=True) + bill_to_last = models.CharField(max_length=64, blank=True) + bill_to_street1 = models.CharField(max_length=128, blank=True) + bill_to_street2 = models.CharField(max_length=128, blank=True) + bill_to_city = models.CharField(max_length=64, blank=True) + bill_to_state = models.CharField(max_length=8, blank=True) + bill_to_postalcode = models.CharField(max_length=16, blank=True) + bill_to_country = models.CharField(max_length=64, blank=True) + bill_to_ccnum = models.CharField(max_length=8, blank=True) # last 4 digits + bill_to_cardtype = models.CharField(max_length=32, blank=True) # a JSON dump of the CC processor response, for completeness - processor_reply_dump = models.TextField(null=True, blank=True) + processor_reply_dump = models.TextField(blank=True) @classmethod def get_cart_for_user(cls, user): """ Always use this to preserve the property that at most 1 order per user has status = 'cart' """ - order, created = cls.objects.get_or_create(user=user, status='cart') - return order + # find the newest element in the db + try: + cart_order = cls.objects.filter(user=user, status='cart').order_by('-id')[:1].get() + except ObjectDoesNotExist: + # if nothing exists in the database, create a new cart + cart_order, _created = cls.objects.get_or_create(user=user, status='cart') + return cart_order @property def total_cost(self): - return sum([i.line_cost for i in self.orderitem_set.filter(status=self.status)]) - - @property - def currency(self): - """Assumes that all cart items are in the same currency""" - items = self.orderitem_set.all() - if not items: - return 'usd' - else: - return items[0].currency + return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status)) def clear(self): """ @@ -87,7 +90,10 @@ class Order(models.Model): self.bill_to_cardtype = cardtype self.processor_reply_dump = processor_reply_dump self.save() - for item in self.orderitem_set.all(): + # this should return all of the objects with the correct types of the + # subclasses + orderitems = OrderItem.objects.filter(order=self).select_subclasses() + for item in orderitems: item.status = 'purchased' item.purchased_callback() item.save() @@ -101,60 +107,38 @@ class OrderItem(models.Model): Each implementation of OrderItem should provide its own purchased_callback as a method. """ + objects = InheritanceManager() order = models.ForeignKey(Order, db_index=True) # this is denormalized, but convenient for SQL queries for reports, etc. user should always be = order.user user = models.ForeignKey(User, db_index=True) # this is denormalized, but convenient for SQL queries for reports, etc. status should always be = order.status status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) qty = models.IntegerField(default=1) - unit_cost = models.FloatField(default=0.0) - line_cost = models.FloatField(default=0.0) # qty * unit_cost + unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) + line_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) # qty * unit_cost line_desc = models.CharField(default="Misc. Item", max_length=1024) - currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes + currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes - def add_to_order(self, *args, **kwargs): + @classmethod + def add_to_order(cls, *args, **kwargs): """ A suggested convenience function for subclasses. """ - raise NotImplementedError + # this is a validation step to verify that the currency of the item we + # are adding is the same as the currency of the order we are adding it + # to + if isinstance(args[0], Order): + currency = kwargs['currency'] if 'currency' in kwargs else 'usd' + order = args[0] + if order.currency != currency and order.orderitem_set.count() > 0: + raise InvalidCartItem(_("Trying to add a different currency into the cart")) def purchased_callback(self): """ This is called on each inventory item in the shopping cart when the purchase goes through. - - NOTE: We want to provide facilities for doing something like - for item in OrderItem.objects.filter(order_id=order_id): - item.purchased_callback() - - Unfortunately the QuerySet used determines the class to be OrderItem, and not its most specific - subclasses. That means this parent class implementation of purchased_callback needs to act as - a dispatcher to call the callback the proper subclasses, and as such it needs to know about all - possible subclasses. - So keep ORDER_ITEM_SUBTYPES up-to-date """ - for cls, lc_classname in ORDER_ITEM_SUBTYPES.iteritems(): - try: - #Uses https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance to test subclass - sub_instance = getattr(self,lc_classname) - sub_instance.purchased_callback() - except (ObjectDoesNotExist, AttributeError): - log.exception('Cannot call purchase_callback on non-existent subclass attribute {0} of OrderItem'\ - .format(lc_classname)) - pass - - def is_of_subtype(self, cls): - """ - Checks if self is also a type of cls, in addition to being an OrderItem - Uses https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance to test for subclass - """ - if cls not in ORDER_ITEM_SUBTYPES: - return False - try: - getattr(self, ORDER_ITEM_SUBTYPES[cls]) - return True - except (ObjectDoesNotExist, AttributeError): - return False + raise NotImplementedError class PaidCourseRegistration(OrderItem): @@ -180,6 +164,8 @@ class PaidCourseRegistration(OrderItem): Returns the order item """ + super(PaidCourseRegistration, cls).add_to_order(order, course_id, cost, currency=currency) + # TODO: Possibly add checking for whether student is already enrolled in course course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to # throw errors if it doesn't @@ -190,6 +176,8 @@ class PaidCourseRegistration(OrderItem): item.line_cost = cost item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) item.currency = currency + order.currency = currency + order.save() item.save() return item @@ -214,45 +202,61 @@ class PaidCourseRegistration(OrderItem): "run:{0}".format(run)]) -class VerifiedCertificate(OrderItem): +class CertificateItem(OrderItem): """ - This is an inventory item for purchasing verified certificates + This is an inventory item for purchasing certificates """ course_id = models.CharField(max_length=128, db_index=True) course_enrollment = models.ForeignKey(CourseEnrollment) + mode = models.SlugField() @classmethod - def add_to_order(cls, order, course_id, cost, currency='usd'): + def add_to_order(cls, order, course_id, cost, mode, currency='usd'): """ - Add a VerifiedCertificate item to an order + Add a CertificateItem to an order + + Returns the CertificateItem object after saving + + `order` - an order that this item should be added to, generally the cart order + `course_id` - the course that we would like to purchase as a CertificateItem + `cost` - the amount the user will be paying for this CertificateItem + `mode` - the course mode that this certificate is going to be issued for + + This item also creates a new enrollment if none exists for this user and this course. + + Example Usage: + cart = Order.get_cart_for_user(user) + CertificateItem.add_to_order(cart, 'edX/Test101/2013_Fall', 30, 'verified') + """ - course_enrollment = CourseEnrollment.create_enrollment(order.user, course_id, mode="verified") + super(CertificateItem, cls).add_to_order(order, course_id, cost, currency=currency) + try: + 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) item, _created = cls.objects.get_or_create( order=order, user=order.user, course_id=course_id, - course_enrollment=course_enrollment + course_enrollment=course_enrollment, + mode=mode ) item.status = order.status item.qty = 1 item.unit_cost = cost item.line_cost = cost - item.line_desc = "Verified Certificate for Course {0}".format(course_id) + item.line_desc = "{mode} certificate for course {course_id}".format(mode=item.mode, + course_id=course_id) item.currency = currency + order.currency = currency + order.save() item.save() return item def purchased_callback(self): """ - When purchase goes through, activate the course enrollment + When purchase goes through, activate and update the course enrollment for the correct mode """ + self.course_enrollment.mode = self.mode + self.course_enrollment.save() self.course_enrollment.activate() - - -# Each entry is a dictionary of ModelName: 'lower_case_model_name' -# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for -# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem -ORDER_ITEM_SUBTYPES = { - PaidCourseRegistration: 'paidcourseregistration', - VerifiedCertificate: 'verifiedcertificate', -} diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 521b9e594e..61a10f2f75 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -4,32 +4,86 @@ Tests for the Shopping Cart from factory import DjangoModelFactory from django.test import TestCase -from shoppingcart.models import Order, VerifiedCertificate +from shoppingcart.models import Order, CertificateItem, InvalidCartItem from student.tests.factories import UserFactory - - -class OrderFactory(DjangoModelFactory): - FACTORY_FOR = Order - - -class VerifiedCertificateFactory(DjangoModelFactory): - FACTORY_FOR = VerifiedCertificate +from student.models import CourseEnrollment class OrderTest(TestCase): def setUp(self): self.user = UserFactory.create() - self.cart = OrderFactory.create(user=self.user, status='cart') self.course_id = "test/course" + self.cost = 40 - def test_add_item_to_cart(self): - pass + 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') + # 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') + 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') + # 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') + # 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 - cost = 30 - iterations = 5 - for _ in xrange(iterations): - VerifiedCertificate.add_to_order(self.cart, self.user, self.course_id, cost) - self.assertEquals(self.cart.total_cost, cost * iterations) + course_costs = [('test/course1', 30), + ('test/course2', 40), + ('test/course3', 10), + ('test/course4', 20)] + for course, cost in course_costs: + CertificateItem.add_to_order(cart, course, cost, 'verified') + self.assertEquals(cart.orderitem_set.count(), len(course_costs)) + self.assertEquals(cart.total_cost, sum(cost for _course, cost in course_costs)) + def test_purchase(self): + # This test is for testing the subclassing functionality of OrderItem, but in + # order to do this, we end up testing the specific functionality of + # 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') + # course enrollment object should be created but still inactive + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + cart.purchase() + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + +class CertificateItemTest(TestCase): + """ + Tests for verifying specific CertificateItem functionality + """ + def setUp(self): + self.user = UserFactory.create() + self.course_id = "test/course" + self.cost = 40 + + def test_existing_enrollment(self): + CourseEnrollment.enroll(self.user, self.course_id) + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + # verify that we are still enrolled + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + cart.purchase() + enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id) + self.assertEquals(enrollment.mode, u'verified') diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 91dff59aed..bdf8eb317f 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -38,7 +38,7 @@ def add_course_to_cart(request, course_id): @login_required def register_for_verified_cert(request, course_id): cart = Order.get_cart_for_user(request.user) - VerifiedCertificate.add_to_order(cart, course_id, 30) + CertificateItem.add_to_order(cart, course_id, 30, 'verified') return HttpResponse("Added") @login_required diff --git a/lms/envs/common.py b/lms/envs/common.py index c5b174b077..8181f97789 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -438,10 +438,10 @@ ZENDESK_API_KEY = None PAYMENT_SUPPORT_EMAIL = 'payment@edx.org' ##### Using cybersource by default ##### CC_PROCESSOR = { - 'CyberSource' : { + 'CyberSource': { 'SHARED_SECRET': '', - 'MERCHANT_ID' : '', - 'SERIAL_NUMBER' : '', + 'MERCHANT_ID': '', + 'SERIAL_NUMBER': '', 'ORDERPAGE_VERSION': '7', 'PURCHASE_ENDPOINT': '', } diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9179315797..d700aaa195 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -53,6 +53,7 @@ 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 From 65f2814d736f52ed22f266b9ef0ca6432862d17c Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 13:20:06 -0700 Subject: [PATCH 022/185] make PaidCourseRegistration mode aware --- common/djangoapps/course_modes/models.py | 15 ++++++++ common/djangoapps/course_modes/tests.py | 3 ++ lms/djangoapps/shoppingcart/exceptions.py | 5 +++ lms/djangoapps/shoppingcart/models.py | 35 ++++++++++++++----- .../shoppingcart/processors/exceptions.py | 3 +- lms/envs/dev.py | 4 +++ 6 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/exceptions.py diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 561c078b3b..3d1c6f0563 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -51,3 +51,18 @@ class CourseMode(models.Model): if not modes: modes = [cls.DEFAULT_MODE] return modes + + @classmethod + def mode_for_course(cls, course_id, mode_slug): + """ + Returns the mode for the course corresponding to mode_slug. + + If this particular mode is not set for the course, returns None + """ + modes = cls.modes_for_course(course_id) + + matched = filter(lambda m: m.slug == mode_slug, modes) + if matched: + return matched[0] + else: + return None diff --git a/common/djangoapps/course_modes/tests.py b/common/djangoapps/course_modes/tests.py index 907797bf17..1fba5ca197 100644 --- a/common/djangoapps/course_modes/tests.py +++ b/common/djangoapps/course_modes/tests.py @@ -60,3 +60,6 @@ class CourseModeModelTest(TestCase): modes = CourseMode.modes_for_course(self.course_id) self.assertEqual(modes, set_modes) + self.assertEqual(mode1, CourseMode.mode_for_course(self.course_id, u'honor')) + self.assertEqual(mode2, CourseMode.mode_for_course(self.course_id, u'verified')) + self.assertIsNone(CourseMode.mode_for_course(self.course_id, 'DNE')) diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py new file mode 100644 index 0000000000..fdfb9ccdb9 --- /dev/null +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -0,0 +1,5 @@ +class PaymentException(Exception): + pass + +class PurchasedCallbackException(PaymentException): + pass \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 3a4039c9e1..7c73adbd9a 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -6,10 +6,17 @@ from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User from django.utils.translation import ugettext as _ from model_utils.managers import InheritanceManager -from courseware.courses import get_course_about_section +from courseware.courses import course_image_url, get_course_about_section + +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor + +from course_modes.models import CourseMode from student.views import course_from_id from student.models import CourseEnrollment from statsd import statsd +from .exceptions import * + log = logging.getLogger("shoppingcart") class InvalidCartItem(Exception): @@ -157,7 +164,7 @@ class PaidCourseRegistration(OrderItem): if item.is_of_subtype(PaidCourseRegistration)] @classmethod - def add_to_order(cls, order, course_id, cost=None, currency=None): + def add_to_order(cls, order, course_id, mode_slug=None, cost=None, currency=None): """ A standardized way to create these objects, with sensible defaults filled in. Will update the cost if called on an order that already carries the course. @@ -171,10 +178,21 @@ class PaidCourseRegistration(OrderItem): # throw errors if it doesn't item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status + if not mode_slug: + mode_slug = CourseMode.DEFAULT_MODE.slug + ### Get this course_mode + course_mode = CourseMode.mode_for_course(course_id, mode_slug) + if not course_mode: + course_mode = CourseMode.DEFAULT_MODE + if not cost: + cost = course_mode.min_price + if not currency: + currency = course_mode.currency item.qty = 1 item.unit_cost = cost item.line_cost = cost - item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) + item.line_desc = 'Registration for Course: {0}. Mode: {1}'.format(get_course_about_section(course, "title"), + course_mode.name) item.currency = currency order.currency = currency order.save() @@ -188,11 +206,12 @@ class PaidCourseRegistration(OrderItem): CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment would in fact be quite silly since there's a clear back door. """ - course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to - # throw errors if it doesn't - # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for - # whatever reason. - CourseEnrollment.objects.get_or_create(user=self.user, course_id=self.course_id) + course_loc = CourseDescriptor.id_to_location(self.course_id) + course_exists = modulestore().has_item(self.course_id, course_loc) + if not course_exists: + raise PurchasedCallbackException( + "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) + CourseEnrollment.enroll(user=self.user, course_id=self.course_id) log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) org, course_num, run = self.course_id.split("/") diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py index e863688133..098ed0f1af 100644 --- a/lms/djangoapps/shoppingcart/processors/exceptions.py +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -1,5 +1,4 @@ -class PaymentException(Exception): - pass +from shoppingcart.exceptions import PaymentException class CCProcessorException(PaymentException): pass diff --git a/lms/envs/dev.py b/lms/envs/dev.py index cc78dcc6ca..554c72dd89 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -270,6 +270,10 @@ CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_P ########################## USER API ######################## EDX_API_KEY = None + +####################### Shoppingcart ########################### +MITX_FEATURES['ENABLE_SHOPPING_CART'] = True + ##################################################################### # Lastly, see if the developer has any local overrides. try: From efbb439cb5daaed34ba87ac08d798a2da1cd1cb7 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 14:49:26 -0700 Subject: [PATCH 023/185] Adding migration to store purchased mode in PaidCourseRegistration --- common/djangoapps/course_modes/models.py | 1 + common/djangoapps/student/models.py | 3 - lms/djangoapps/shoppingcart/exceptions.py | 5 +- ...__add_field_paidcourseregistration_mode.py | 114 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 18 +-- lms/djangoapps/shoppingcart/views.py | 2 +- 6 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 3d1c6f0563..6362b7061f 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -33,6 +33,7 @@ class CourseMode(models.Model): currency = models.CharField(default="usd", max_length=8) DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd') + DEFAULT_MODE_SLUG = 'honor' class Meta: """ meta attributes of this model """ diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 6b5897e97d..3d977b28c9 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -827,9 +827,6 @@ class CourseEnrollment(models.Model): @classmethod def is_enrolled(cls, user, course_id): """ - Remove the user from a given course. If the relevant `CourseEnrollment` - object doesn't exist, we log an error but don't throw an exception. - Returns True if the user is enrolled in the course (the entry must exist and it must have `is_active=True`). Otherwise, returns False. diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py index fdfb9ccdb9..5c147194a1 100644 --- a/lms/djangoapps/shoppingcart/exceptions.py +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -2,4 +2,7 @@ class PaymentException(Exception): pass class PurchasedCallbackException(PaymentException): - pass \ No newline at end of file + pass + +class InvalidCartItem(PaymentException): + pass diff --git a/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py new file mode 100644 index 0000000000..1a6730c769 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py @@ -0,0 +1,114 @@ +# -*- 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 field 'PaidCourseRegistration.mode' + db.add_column('shoppingcart_paidcourseregistration', 'mode', + self.gf('django.db.models.fields.SlugField')(default='honor', max_length=50), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'PaidCourseRegistration.mode' + db.delete_column('shoppingcart_paidcourseregistration', 'mode') + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 7c73adbd9a..e2dad911da 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -19,9 +19,6 @@ from .exceptions import * log = logging.getLogger("shoppingcart") -class InvalidCartItem(Exception): - pass - ORDER_STATUSES = ( ('cart', 'cart'), ('purchased', 'purchased'), @@ -153,6 +150,7 @@ class PaidCourseRegistration(OrderItem): This is an inventory item for paying for a course registration """ course_id = models.CharField(max_length=128, db_index=True) + mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG) @classmethod def part_of_order(cls, order, course_id): @@ -164,7 +162,7 @@ class PaidCourseRegistration(OrderItem): if item.is_of_subtype(PaidCourseRegistration)] @classmethod - def add_to_order(cls, order, course_id, mode_slug=None, cost=None, currency=None): + def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): """ A standardized way to create these objects, with sensible defaults filled in. Will update the cost if called on an order that already carries the course. @@ -178,16 +176,18 @@ class PaidCourseRegistration(OrderItem): # throw errors if it doesn't item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status - if not mode_slug: - mode_slug = CourseMode.DEFAULT_MODE.slug + ### Get this course_mode course_mode = CourseMode.mode_for_course(course_id, mode_slug) if not course_mode: + # user could have specified a mode that's not set, in that case return the DEFAULT_MODE course_mode = CourseMode.DEFAULT_MODE if not cost: cost = course_mode.min_price if not currency: currency = course_mode.currency + + item.mode = course_mode.slug item.qty = 1 item.unit_cost = cost item.line_cost = cost @@ -202,8 +202,8 @@ class PaidCourseRegistration(OrderItem): def purchased_callback(self): """ When purchased, this should enroll the user in the course. We are assuming that - course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found in - CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment + course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found + in CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment would in fact be quite silly since there's a clear back door. """ course_loc = CourseDescriptor.id_to_location(self.course_id) @@ -211,7 +211,7 @@ class PaidCourseRegistration(OrderItem): if not course_exists: raise PurchasedCallbackException( "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) - CourseEnrollment.enroll(user=self.user, course_id=self.course_id) + CourseEnrollment.enroll(user=self.user, course_id=self.course_id, mode=self.mode) log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) org, course_num, run = self.course_id.split("/") diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index bdf8eb317f..52837228b9 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -24,7 +24,7 @@ def add_course_to_cart(request, course_id): 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))) - if CourseEnrollment.objects.filter(user=request.user, course_id=course_id).exists(): + if CourseEnrollment.is_enrolled(user=request.user, course_id=course_id): return HttpResponseNotFound(_('You are already registered in course {0}.'.format(course_id))) try: PaidCourseRegistration.add_to_order(cart, course_id) From 64c8a7444fb3bb2772bd9dfd11864978adc38ea4 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 18:17:06 -0700 Subject: [PATCH 024/185] add handling of CyberSource non-ACCEPT decisions --- .../shoppingcart/processors/CyberSource.py | 242 +++++++++++++++--- .../shoppingcart/processors/exceptions.py | 3 + lms/djangoapps/shoppingcart/views.py | 4 +- lms/templates/shoppingcart/error.html | 14 + 4 files changed, 226 insertions(+), 37 deletions(-) create mode 100644 lms/templates/shoppingcart/error.html diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 75ad754237..d8e53843cc 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -8,18 +8,21 @@ import binascii import re import json from collections import OrderedDict, defaultdict +from decimal import Decimal, InvalidOperation from hashlib import sha1 +from textwrap import dedent from django.conf import settings from django.utils.translation import ugettext as _ from mitxmako.shortcuts import render_to_string from shoppingcart.models import Order -from .exceptions import CCProcessorException, CCProcessorDataException, CCProcessorWrongAmountException +from .exceptions import * shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') +payment_support_email = settings.PAYMENT_SUPPORT_EMAIL def process_postpay_callback(request): """ @@ -34,27 +37,23 @@ def process_postpay_callback(request): return a helpful-enough error message in error_html. """ params = request.POST.dict() - if verify_signatures(params): - try: - result = payment_accepted(params) - if result['accepted']: - # SUCCESS CASE first, rest are some sort of oddity - record_purchase(params, result['order']) - return {'success': True, - 'order': result['order'], - 'error_html': ''} - else: - return {'success': False, - 'order': result['order'], - 'error_html': get_processor_error_html(params)} - except CCProcessorException as e: + try: + verify_signatures(params) + result = payment_accepted(params) + if result['accepted']: + # SUCCESS CASE first, rest are some sort of oddity + record_purchase(params, result['order']) + return {'success': True, + 'order': result['order'], + 'error_html': ''} + else: return {'success': False, - 'order': None, #due to exception we may not have the order - 'error_html': get_exception_html(params, e)} - else: + 'order': result['order'], + 'error_html': get_processor_decline_html(params)} + except CCProcessorException as e: return {'success': False, - 'order': None, - 'error_html': get_signature_error_html(params)} + 'order': None, #due to exception we may not have the order + 'error_html': get_processor_exception_html(params, e)} def hash(value): @@ -87,15 +86,18 @@ def sign(params): def verify_signatures(params): """ Verify the signatures accompanying the POST back from Cybersource Hosted Order Page + + returns silently if verified + + raises CCProcessorSignatureException if not verified """ signed_fields = params.get('signedFields', '').split(',') data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) signed_fields_sig = hash(params.get('signedFields', '')) data += ",signedFieldsPublicSignature=" + signed_fields_sig returned_sig = params.get('signedDataPublicSignature','') - if not returned_sig: - return False - return hash(data) == returned_sig + if hash(data) != returned_sig: + raise CCProcessorSignatureException() def render_purchase_form_html(cart, user): @@ -130,11 +132,18 @@ def render_purchase_form_html(cart, user): def payment_accepted(params): """ Check that cybersource has accepted the payment + params: a dictionary of POST parameters returned by CyberSource in their post-payment callback + + returns: true if the payment was correctly accepted, for the right amount + false if the payment was not accepted + + raises: CCProcessorDataException if the returned message did not provide required parameters + CCProcessorWrongAmountException if the amount charged is different than the order amount + """ #make sure required keys are present and convert their values to the right type valid_params = {} for key, type in [('orderNumber', int), - ('ccAuthReply_amount', float), ('orderCurrency', str), ('decision', str)]: if key not in params: @@ -154,7 +163,16 @@ def payment_accepted(params): raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system.")) if valid_params['decision'] == 'ACCEPT': - if valid_params['ccAuthReply_amount'] == order.total_cost and valid_params['orderCurrency'] == order.currency: + try: + # Moved reading of charged_amount from the valid_params loop above because + # only 'ACCEPT' messages have a 'ccAuthReply_amount' parameter + charged_amt = Decimal(params['ccAuthReply_amount']) + except InvalidOperation: + raise CCProcessorDataException( + _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) + ) + + if charged_amt == order.total_cost and valid_params['orderCurrency'] == order.currency: return {'accepted': True, 'amt_charged': valid_params['ccAuthReply_amount'], 'currency': valid_params['orderCurrency'], @@ -197,21 +215,67 @@ def record_purchase(params, order): processor_reply_dump=json.dumps(params) ) -def get_processor_error_html(params): - """Have to parse through the error codes for all the other cases""" - return "

    ERROR!

    " +def get_processor_decline_html(params): + """Have to parse through the error codes to return a helpful message""" + msg = _(dedent( + """ +

    + Sorry! Our payment processor did not accept your payment. + The decision in they returned was {decision}, + and the reason was {reason_code}:{reason_msg}. + You were not charged. Please try a different form of payment. + Contact us with payment-specific questions at {email}. +

    + """)) -def get_exception_html(params, exp): + return msg.format( + decision=params['decision'], + reason_code=params['reasonCode'], + reason_msg=REASONCODE_MAP[params['reasonCode']], + email=payment_support_email) + + +def get_processor_exception_html(params, exception): """Return error HTML associated with exception""" - return "

    EXCEPTION!

    " -def get_signature_error_html(params): - """Return error HTML associated with signature failure""" - return "

    EXCEPTION!

    " + if isinstance(exception, CCProcessorDataException): + msg = _(dedent( + """ +

    + Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! + We apologize that we cannot verify whether the charge went through and take further action on your order. + The specific error message is: {msg}. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) + return msg + elif isinstance(exception, CCProcessorWrongAmountException): + msg = _(dedent( + """ +

    + Sorry! Due to an error your purchase was charged for a different amount than the order total! + The specific error message is: {msg}. + Your credit card has probably been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) + return msg + elif isinstance(exception, CCProcessorSignatureException): + msg = _(dedent( + """ +

    + Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are + unable to validate that the message actually came from the payment processor. + We apologize that we cannot verify whether the charge went through and take further action on your order. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

    + """.format(email=payment_support_email))) + return msg + + # fallthrough case, which basically never happens + return '

    EXCEPTION!

    ' -CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") -CARDTYPE_MAP.update( +CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN").update( { '001': 'Visa', '002': 'MasterCard', @@ -233,3 +297,111 @@ CARDTYPE_MAP.update( '043': 'GE Money UK card' } ) + +REASONCODE_MAP = defaultdict(lambda:"UNKNOWN REASON") +REASONCODE_MAP.update( + { + '100' : _('Successful transaction.'), + '101' : _('The request is missing one or more required fields.'), + '102' : _('One or more fields in the request contains invalid data.'), + '104' : _(dedent( + """ + The merchantReferenceCode sent with this authorization request matches the + merchantReferenceCode of another authorization request that you sent in the last 15 minutes. + Possible fix: retry the payment after 15 minutes. + """)), + '150' : _('Error: General system failure. Possible fix: retry the payment after a few minutes.'), + '151' : _(dedent( + """ + Error: The request was received but there was a server timeout. + This error does not include timeouts between the client and the server. + Possible fix: retry the payment after some time. + """)), + '152' : _(dedent( + """ + Error: The request was received, but a service did not finish running in time + Possible fix: retry the payment after some time. + """)), + '201' : _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'), + '202' : _(dedent( + """ + Expired card. You might also receive this if the expiration date you + provided does not match the date the issuing bank has on file. + Possible fix: retry with another form of payment + """)), + '203' : _(dedent( + """ + General decline of the card. No other information provided by the issuing bank. + Possible fix: retry with another form of payment + """)), + '204' : _('Insufficient funds in the account. Possible fix: retry with another form of payment'), + # 205 was Stolen or lost card. Might as well not show this message to the person using such a card. + '205' : _('Unknown reason'), + '207' : _('Issuing bank unavailable. Possible fix: retry again after a few minutes'), + '208' : _(dedent( + """ + Inactive card or card not authorized for card-not-present transactions. + Possible fix: retry with another form of payment + """)), + '210' : _('The card has reached the credit limit. Possible fix: retry with another form of payment'), + '211' : _('Invalid card verification number. Possible fix: retry with another form of payment'), + # 221 was The customer matched an entry on the processor's negative file. + # Might as well not show this message to the person using such a card. + '221' : _('Unknown reason'), + '231' : _('Invalid account number. Possible fix: retry with another form of payment'), + '232' : _(dedent( + """ + The card type is not accepted by the payment processor. + Possible fix: retry with another form of payment + """)), + '233' : _('General decline by the processor. Possible fix: retry with another form of payment'), + '234' : _(dedent( + """ + There is a problem with our CyberSource merchant configuration. Please let us know at {0} + """.format(payment_support_email))), + # reason code 235 only applies if we are processing a capture through the API. so we should never see it + '235' : _('The requested amount exceeds the originally authorized amount.'), + '236' : _('Processor Failure. Possible fix: retry the payment'), + # reason code 238 only applies if we are processing a capture through the API. so we should never see it + '238' : _('The authorization has already been captured'), + # reason code 239 only applies if we are processing a capture or credit through the API, + # so we should never see it + '239' : _('The requested transaction amount must match the previous transaction amount.'), + '240' : _(dedent( + """ + The card type sent is invalid or does not correlate with the credit card number. + Possible fix: retry with the same card or another form of payment + """)), + # reason code 241 only applies when we are processing a capture or credit through the API, + # so we should never see it + '241' : _('The request ID is invalid.'), + # reason code 242 occurs if there was not a previously successful authorization request or + # if the previously successful authorization has already been used by another capture request. + # This reason code only applies when we are processing a capture through the API + # so we should never see it + '242' : _(dedent( + """ + You requested a capture through the API, but there is no corresponding, unused authorization record. + """)), + # we should never see 243 + '243' : _('The transaction has already been settled or reversed.'), + # reason code 246 applies only if we are processing a void through the API. so we should never see it + '246' : _(dedent( + """ + The capture or credit is not voidable because the capture or credit information has already been + submitted to your processor. Or, you requested a void for a type of transaction that cannot be voided. + """)), + # reason code 247 applies only if we are processing a void through the API. so we should never see it + '247' : _('You requested a credit for a capture that was previously voided'), + '250' : _(dedent( + """ + Error: The request was received, but there was a timeout at the payment processor. + Possible fix: retry the payment. + """)), + '520' : _(dedent( + """ + The authorization request was approved by the issuing bank but declined by CyberSource.' + Possible fix: retry with a different form of payment. + """)), + } +) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py index 098ed0f1af..6779ac11a6 100644 --- a/lms/djangoapps/shoppingcart/processors/exceptions.py +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -3,6 +3,9 @@ from shoppingcart.exceptions import PaymentException class CCProcessorException(PaymentException): pass +class CCProcessorSignatureException(CCProcessorException): + pass + class CCProcessorDataException(CCProcessorException): pass diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 52837228b9..85334df6a6 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -86,8 +86,8 @@ def postpay_callback(request): if result['success']: return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) else: - return render_to_response('shoppingcart.processor_error.html', {'order':result['order'], - 'error_html': result['error_html']}) + return render_to_response('shoppingcart/error.html', {'order':result['order'], + 'error_html': result['error_html']}) @login_required def show_receipt(request, ordernum): diff --git a/lms/templates/shoppingcart/error.html b/lms/templates/shoppingcart/error.html new file mode 100644 index 0000000000..da88dc1a78 --- /dev/null +++ b/lms/templates/shoppingcart/error.html @@ -0,0 +1,14 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +<%inherit file="../main.html" /> + +<%block name="title">${_("Payment Error")} + + +
    +

    ${_("There was an error processing your order!")}

    + ${error_html} + +

    ${_("Return to cart to retry payment")}

    +
    From 3ecdb6711f8fcf279eda945c038e4996f1f1ebda Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 18:55:59 -0700 Subject: [PATCH 025/185] change method sig of process_postpay_callback --- lms/djangoapps/shoppingcart/models.py | 14 +++++++------- .../shoppingcart/processors/CyberSource.py | 13 +++++++------ lms/djangoapps/shoppingcart/views.py | 3 ++- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index e2dad911da..895f466273 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -158,8 +158,7 @@ class PaidCourseRegistration(OrderItem): Is the course defined by course_id in the order? """ return course_id in [item.paidcourseregistration.course_id - for item in order.orderitem_set.all() - if item.is_of_subtype(PaidCourseRegistration)] + for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")] @classmethod def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): @@ -169,15 +168,11 @@ class PaidCourseRegistration(OrderItem): Returns the order item """ - super(PaidCourseRegistration, cls).add_to_order(order, course_id, cost, currency=currency) - # TODO: Possibly add checking for whether student is already enrolled in course course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to # throw errors if it doesn't - item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) - item.status = order.status - ### Get this course_mode + ### handle default arguments for mode_slug, cost, currency course_mode = CourseMode.mode_for_course(course_id, mode_slug) if not course_mode: # user could have specified a mode that's not set, in that case return the DEFAULT_MODE @@ -187,6 +182,11 @@ class PaidCourseRegistration(OrderItem): if not currency: currency = course_mode.currency + super(PaidCourseRegistration, cls).add_to_order(order, course_id, cost, currency=currency) + + item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) + item.status = order.status + item.mode = course_mode.slug item.qty = 1 item.unit_cost = cost diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index d8e53843cc..e7f593db4a 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -24,7 +24,7 @@ orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION' purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') payment_support_email = settings.PAYMENT_SUPPORT_EMAIL -def process_postpay_callback(request): +def process_postpay_callback(params): """ The top level call to this module, basically This function is handed the callback request after the customer has entered the CC info and clicked "buy" @@ -36,7 +36,6 @@ def process_postpay_callback(request): If unsuccessful this function should not have those side effects but should try to figure out why and return a helpful-enough error message in error_html. """ - params = request.POST.dict() try: verify_signatures(params) result = payment_accepted(params) @@ -164,17 +163,18 @@ def payment_accepted(params): if valid_params['decision'] == 'ACCEPT': try: - # Moved reading of charged_amount from the valid_params loop above because + # Moved reading of charged_amount here from the valid_params loop above because # only 'ACCEPT' messages have a 'ccAuthReply_amount' parameter charged_amt = Decimal(params['ccAuthReply_amount']) except InvalidOperation: raise CCProcessorDataException( - _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) + _("The payment processor returned a badly-typed value {0} for param {1}.".format( + params['ccAuthReply_amount'], 'ccAuthReply_amount')) ) if charged_amt == order.total_cost and valid_params['orderCurrency'] == order.currency: return {'accepted': True, - 'amt_charged': valid_params['ccAuthReply_amount'], + 'amt_charged': charged_amt, 'currency': valid_params['orderCurrency'], 'order': order} else: @@ -275,7 +275,8 @@ def get_processor_exception_html(params, exception): return '

    EXCEPTION!

    ' -CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN").update( +CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") +CARDTYPE_MAP.update( { '001': 'Visa', '002': 'MasterCard', diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 85334df6a6..0d046b9a4b 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -82,7 +82,8 @@ def postpay_callback(request): If unsuccessful the order will be left untouched and HTML messages giving more detailed error info will be returned. """ - result = process_postpay_callback(request) + params = request.POST.dict() + result = process_postpay_callback(params) if result['success']: return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) else: From 3da149ad7680601013572e2b7a89a3f28d78f401 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 21:36:32 -0700 Subject: [PATCH 026/185] Start of tests for CyberSource processor --- .../shoppingcart/processors/CyberSource.py | 28 ++++---- .../shoppingcart/processors/__init__.py | 40 +---------- .../shoppingcart/processors/tests/__init__.py | 0 .../processors/tests/test_CyberSource.py | 69 +++++++++++++++++++ 4 files changed, 85 insertions(+), 52 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/processors/tests/__init__.py create mode 100644 lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index e7f593db4a..20b2b1bda8 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -17,13 +17,6 @@ from mitxmako.shortcuts import render_to_string from shoppingcart.models import Order from .exceptions import * -shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') -merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') -serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') -orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') -purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') -payment_support_email = settings.PAYMENT_SUPPORT_EMAIL - def process_postpay_callback(params): """ The top level call to this module, basically @@ -59,6 +52,7 @@ def hash(value): """ Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page """ + shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') hash_obj = hmac.new(shared_secret, value, sha1) return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want @@ -68,6 +62,10 @@ def sign(params): params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ + merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') + orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') + serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') + params['merchantID'] = merchant_id params['orderPage_timestamp'] = int(time.time()*1000) params['orderPage_version'] = orderPage_version @@ -82,7 +80,7 @@ def sign(params): return params -def verify_signatures(params): +def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='signedDataPublicSignature'): """ Verify the signatures accompanying the POST back from Cybersource Hosted Order Page @@ -90,11 +88,11 @@ def verify_signatures(params): raises CCProcessorSignatureException if not verified """ - signed_fields = params.get('signedFields', '').split(',') + signed_fields = params.get(signed_fields_key, '').split(',') data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) - signed_fields_sig = hash(params.get('signedFields', '')) + signed_fields_sig = hash(params.get(signed_fields_key, '')) data += ",signedFieldsPublicSignature=" + signed_fields_sig - returned_sig = params.get('signedDataPublicSignature','') + returned_sig = params.get(full_sig_key, '') if hash(data) != returned_sig: raise CCProcessorSignatureException() @@ -103,11 +101,12 @@ def render_purchase_form_html(cart, user): """ 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','') + total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() params = OrderedDict() - params['comment'] = 'Stanford OpenEdX Purchase' params['amount'] = amount params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' @@ -217,6 +216,8 @@ def record_purchase(params, order): def get_processor_decline_html(params): """Have to parse through the error codes to return a helpful message""" + payment_support_email = settings.PAYMENT_SUPPORT_EMAIL + msg = _(dedent( """

    @@ -238,6 +239,7 @@ def get_processor_decline_html(params): def get_processor_exception_html(params, exception): """Return error HTML associated with exception""" + payment_support_email = settings.PAYMENT_SUPPORT_EMAIL if isinstance(exception, CCProcessorDataException): msg = _(dedent( """ @@ -359,7 +361,7 @@ REASONCODE_MAP.update( '234' : _(dedent( """ There is a problem with our CyberSource merchant configuration. Please let us know at {0} - """.format(payment_support_email))), + """.format(settings.PAYMENT_SUPPORT_EMAIL))), # reason code 235 only applies if we are processing a capture through the API. so we should never see it '235' : _('The requested amount exceeds the originally authorized amount.'), '236' : _('Processor Failure. Possible fix: retry the payment'), diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index 45a6e3114d..bbbbe41cde 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -3,11 +3,7 @@ from django.conf import settings ### Now code that determines, using settings, which actual processor implementation we're using. processor_name = settings.CC_PROCESSOR.keys()[0] module = __import__('shoppingcart.processors.' + processor_name, - fromlist=['sign', - 'verify', - 'render_purchase_form_html' - 'payment_accepted', - 'record_purchase', + fromlist=['render_purchase_form_html' 'process_postpay_callback', ]) @@ -34,37 +30,3 @@ def process_postpay_callback(*args, **kwargs): """ return module.process_postpay_callback(*args, **kwargs) -def sign(*args, **kwargs): - """ - Given a dict (or OrderedDict) of parameters to send to the - credit card processor, signs them in the manner expected by - the processor - - Returns a dict containing the signature - """ - return module.sign(*args, **kwargs) - -def verify(*args, **kwargs): - """ - Given a dict (or OrderedDict) of parameters to returned by the - credit card processor, verifies them in the manner specified by - the processor - - Returns a boolean - """ - return module.sign(*args, **kwargs) - -def payment_accepted(*args, **kwargs): - """ - Given params returned by the CC processor, check that processor has accepted the payment - Returns a dict of {accepted:bool, amt_charged:float, currency:str, order:Order} - """ - return module.payment_accepted(*args, **kwargs) - -def record_purchase(*args, **kwargs): - """ - Given params returned by the CC processor, record that the purchase has occurred in - the database and also run callbacks - """ - return module.record_purchase(*args, **kwargs) - diff --git a/lms/djangoapps/shoppingcart/processors/tests/__init__.py b/lms/djangoapps/shoppingcart/processors/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py new file mode 100644 index 0000000000..0dc3887437 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -0,0 +1,69 @@ +""" +Tests for the CyberSource processor handler +""" +from collections import OrderedDict +from django.test import TestCase +from django.test.utils import override_settings +from django.conf import settings +from shoppingcart.processors.CyberSource import * +from shoppingcart.processors.exceptions import CCProcessorSignatureException + +TEST_CC_PROCESSOR = { + 'CyberSource' : { + 'SHARED_SECRET': 'secret', + 'MERCHANT_ID' : 'edx_test', + 'SERIAL_NUMBER' : '12345', + 'ORDERPAGE_VERSION': '7', + 'PURCHASE_ENDPOINT': '', + } +} + +@override_settings(CC_PROCESSOR=TEST_CC_PROCESSOR) +class CyberSourceTests(TestCase): + + def setUp(self): + pass + + def test_override_settings(self): + self.assertEquals(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') + self.assertEquals(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') + + def test_hash(self): + """ + Tests the hash function. Basically just hardcodes the answer. + """ + self.assertEqual(hash('test'), 'GqNJWF7X7L07nEhqMAZ+OVyks1Y=') + self.assertEqual(hash('edx '), '/KowheysqM2PFYuxVKg0P8Flfk4=') + + def test_sign_then_verify(self): + """ + "loopback" test: + Tests the that the verify function verifies parameters signed by the sign function + """ + params = OrderedDict() + params['amount'] = "12.34" + params['currency'] = 'usd' + params['orderPage_transactionType'] = 'sale' + params['orderNumber'] = "567" + + verify_signatures(sign(params), signed_fields_key='orderPage_signedFields', + full_sig_key='orderPage_signaturePublic') + + # if the above verify_signature fails it will throw an exception, so basically we're just + # testing for the absence of that exception. the trivial assert below does that + self.assertEqual(1, 1) + + def test_verify_exception(self): + """ + Tests that failure to verify raises the proper CCProcessorSignatureException + """ + params = OrderedDict() + params['a'] = 'A' + params['b'] = 'B' + params['signedFields'] = 'A,B' + params['signedDataPublicSignature'] = 'WONTVERIFY' + + with self.assertRaises(CCProcessorSignatureException): + verify_signatures(params) + + From d38748b3005472a5fcbda71829363ab01e1abd90 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 23:58:27 -0700 Subject: [PATCH 027/185] 100% coverage on CyberSource.py --- .../shoppingcart/processors/CyberSource.py | 27 +-- .../processors/tests/test_CyberSource.py | 224 +++++++++++++++++- lms/djangoapps/shoppingcart/views.py | 2 +- 3 files changed, 232 insertions(+), 21 deletions(-) diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 20b2b1bda8..740908624c 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -45,7 +45,7 @@ def process_postpay_callback(params): except CCProcessorException as e: return {'success': False, 'order': None, #due to exception we may not have the order - 'error_html': get_processor_exception_html(params, e)} + 'error_html': get_processor_exception_html(e)} def hash(value): @@ -57,7 +57,7 @@ def hash(value): return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want -def sign(params): +def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='orderPage_signaturePublic'): """ params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource @@ -74,8 +74,8 @@ def sign(params): values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) fields_sig = hash(fields) values += ",signedFieldsPublicSignature=" + fields_sig - params['orderPage_signaturePublic'] = hash(values) - params['orderPage_signedFields'] = fields + params[full_sig_key] = hash(values) + params[signed_fields_key] = fields return params @@ -97,7 +97,7 @@ def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='si raise CCProcessorSignatureException() -def render_purchase_form_html(cart, user): +def render_purchase_form_html(cart): """ Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource """ @@ -111,14 +111,6 @@ def render_purchase_form_html(cart, user): params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) - idx=1 - for item in cart_items: - prefix = "item_{0:d}_".format(idx) - params[prefix+'productSKU'] = "{0:d}".format(item.id) - params[prefix+'quantity'] = item.qty - params[prefix+'productName'] = item.line_desc - params[prefix+'unitPrice'] = item.unit_cost - params[prefix+'taxAmount'] = "0.00" signed_param_dict = sign(params) return render_to_string('shoppingcart/cybersource_form.html', { @@ -179,14 +171,14 @@ def payment_accepted(params): else: raise CCProcessorWrongAmountException( _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}."\ - .format(valid_params['ccAuthReply_amount'], valid_params['orderCurrency'], + .format(charged_amt, valid_params['orderCurrency'], order.total_cost, order.currency)) ) else: return {'accepted': False, 'amt_charged': 0, 'currency': 'usd', - 'order': None} + 'order': order} def record_purchase(params, order): @@ -236,7 +228,7 @@ def get_processor_decline_html(params): email=payment_support_email) -def get_processor_exception_html(params, exception): +def get_processor_exception_html(exception): """Return error HTML associated with exception""" payment_support_email = settings.PAYMENT_SUPPORT_EMAIL @@ -267,10 +259,11 @@ def get_processor_exception_html(params, exception):

    Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are unable to validate that the message actually came from the payment processor. + The specific error message is: {msg}. We apologize that we cannot verify whether the charge went through and take further action on your order. Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}.

    - """.format(email=payment_support_email))) + """.format(msg=exception.message, email=payment_support_email))) return msg # fallthrough case, which basically never happens diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py index 0dc3887437..df719d33b3 100644 --- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -5,8 +5,12 @@ from collections import OrderedDict from django.test import TestCase from django.test.utils import override_settings from django.conf import settings +from student.tests.factories import UserFactory +from shoppingcart.models import Order, OrderItem from shoppingcart.processors.CyberSource import * -from shoppingcart.processors.exceptions import CCProcessorSignatureException +from shoppingcart.processors.exceptions import * +from mock import patch, Mock + TEST_CC_PROCESSOR = { 'CyberSource' : { @@ -25,8 +29,8 @@ class CyberSourceTests(TestCase): pass def test_override_settings(self): - self.assertEquals(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') - self.assertEquals(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') + self.assertEqual(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') + self.assertEqual(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') def test_hash(self): """ @@ -66,4 +70,218 @@ class CyberSourceTests(TestCase): with self.assertRaises(CCProcessorSignatureException): verify_signatures(params) + def test_get_processor_decline_html(self): + """ + Tests the processor decline html message + """ + DECISION = 'REJECT' + for code, reason in REASONCODE_MAP.iteritems(): + params={ + 'decision': DECISION, + 'reasonCode': code, + } + html = get_processor_decline_html(params) + self.assertIn(DECISION, html) + self.assertIn(reason, html) + self.assertIn(code, html) + self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, html) + def test_get_processor_exception_html(self): + """ + Tests the processor exception html message + """ + for type in [CCProcessorSignatureException, CCProcessorWrongAmountException, CCProcessorDataException]: + error_msg = "An exception message of with exception type {0}".format(str(type)) + exception = type(error_msg) + html = get_processor_exception_html(exception) + self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, html) + self.assertIn('Sorry!', html) + self.assertIn(error_msg, html) + + # test base case + self.assertIn("EXCEPTION!", get_processor_exception_html(CCProcessorException())) + + def test_record_purchase(self): + """ + Tests record_purchase with good and without returned CCNum + """ + student1 = UserFactory() + student1.save() + student2 = UserFactory() + student2.save() + params_cc = {'card_accountNumber':'1234', 'card_cardType':'001', 'billTo_firstName':student1.first_name} + params_nocc = {'card_accountNumber':'', 'card_cardType':'002', 'billTo_firstName':student2.first_name} + order1 = Order.get_cart_for_user(student1) + order2 = Order.get_cart_for_user(student2) + record_purchase(params_cc, order1) + record_purchase(params_nocc, order2) + self.assertEqual(order1.bill_to_ccnum, '1234') + self.assertEqual(order1.bill_to_cardtype, 'Visa') + self.assertEqual(order1.bill_to_first, student1.first_name) + self.assertEqual(order1.status, 'purchased') + + order2 = Order.objects.get(user=student2) + self.assertEqual(order2.bill_to_ccnum, '####') + self.assertEqual(order2.bill_to_cardtype, 'MasterCard') + self.assertEqual(order2.bill_to_first, student2.first_name) + self.assertEqual(order2.status, 'purchased') + + def test_payment_accepted_invalid_dict(self): + """ + Tests exception is thrown when params to payment_accepted don't have required key + or have an bad value + """ + baseline = { + 'orderNumber': '1', + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + } + wrong = { + 'orderNumber': 'k', + } + # tests for missing key + for key in baseline: + params = baseline.copy() + del params[key] + with self.assertRaises(CCProcessorDataException): + payment_accepted(params) + + # tests for keys with value that can't be converted to proper type + for key in wrong: + params = baseline.copy() + params[key] = wrong[key] + with self.assertRaises(CCProcessorDataException): + payment_accepted(params) + + def test_payment_accepted_order(self): + """ + Tests payment_accepted cases with an order + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + 'ccAuthReply_amount': '0.00' + } + + # tests for an order number that doesn't match up + params_bad_ordernum = params.copy() + params_bad_ordernum['orderNumber'] = str(order1.id+10) + with self.assertRaises(CCProcessorDataException): + payment_accepted(params_bad_ordernum) + + # tests for a reply amount of the wrong type + params_wrong_type_amt = params.copy() + params_wrong_type_amt['ccAuthReply_amount'] = 'ab' + with self.assertRaises(CCProcessorDataException): + payment_accepted(params_wrong_type_amt) + + # tests for a reply amount of the wrong type + params_wrong_amt = params.copy() + params_wrong_amt['ccAuthReply_amount'] = '1.00' + with self.assertRaises(CCProcessorWrongAmountException): + payment_accepted(params_wrong_amt) + + # tests for a not accepted order + params_not_accepted = params.copy() + params_not_accepted['decision'] = "REJECT" + self.assertFalse(payment_accepted(params_not_accepted)['accepted']) + + # finally, tests an accepted order + self.assertTrue(payment_accepted(params)['accepted']) + + @patch('shoppingcart.processors.CyberSource.render_to_string', autospec=True) + def test_render_purchase_form_html(self, render): + """ + Tests the rendering of the purchase form + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + item1 = OrderItem(order=order1, user=student1, unit_cost=1.0, line_cost=1.0) + item1.save() + html = render_purchase_form_html(order1) + ((template, context), render_kwargs) = render.call_args + + self.assertEqual(template, 'shoppingcart/cybersource_form.html') + self.assertDictContainsSubset({'amount': '1.00', + 'currency': 'usd', + 'orderPage_transactionType': 'sale', + 'orderNumber':str(order1.id)}, + context['params']) + + def test_process_postpay_exception(self): + """ + Tests the exception path of process_postpay_callback + """ + baseline = { + 'orderNumber': '1', + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + } + # tests for missing key + for key in baseline: + params = baseline.copy() + del params[key] + result = process_postpay_callback(params) + self.assertFalse(result['success']) + self.assertIsNone(result['order']) + self.assertIn('error_msg', result['error_html']) + + @patch('shoppingcart.processors.CyberSource.verify_signatures', Mock(return_value=True)) + def test_process_postpay_accepted(self): + """ + Tests the ACCEPTED path of process_postpay + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + 'ccAuthReply_amount': '0.00' + } + result = process_postpay_callback(params) + self.assertTrue(result['success']) + self.assertEqual(result['order'], order1) + order1 = Order.objects.get(id=order1.id) # reload from DB to capture side-effect of process_postpay_callback + self.assertEqual(order1.status, 'purchased') + self.assertFalse(result['error_html']) + + @patch('shoppingcart.processors.CyberSource.verify_signatures', Mock(return_value=True)) + def test_process_postpay_not_accepted(self): + """ + Tests the non-ACCEPTED path of process_postpay + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'REJECT', + 'ccAuthReply_amount': '0.00', + 'reasonCode': '207' + } + result = process_postpay_callback(params) + self.assertFalse(result['success']) + self.assertEqual(result['order'], order1) + self.assertEqual(order1.status, 'cart') + self.assertIn(REASONCODE_MAP['207'], result['error_html']) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 0d046b9a4b..fa8345f33e 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -47,7 +47,7 @@ def show_cart(request): total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() - form_html = render_purchase_form_html(cart, request.user) + form_html = render_purchase_form_html(cart) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, 'amount': amount, From 76766cbcaebab974f7a920ee2d489ea41d49e41c Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 09:31:32 -0400 Subject: [PATCH 028/185] Some pep8/pylint cleanup --- lms/djangoapps/shoppingcart/exceptions.py | 2 + .../shoppingcart/processors/CyberSource.py | 188 +++++++++--------- .../shoppingcart/processors/__init__.py | 3 +- .../shoppingcart/processors/exceptions.py | 6 +- .../processors/tests/test_CyberSource.py | 25 +-- lms/djangoapps/shoppingcart/views.py | 8 +- 6 files changed, 124 insertions(+), 108 deletions(-) diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py index 5c147194a1..029dc079bb 100644 --- a/lms/djangoapps/shoppingcart/exceptions.py +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -1,8 +1,10 @@ class PaymentException(Exception): pass + class PurchasedCallbackException(PaymentException): pass + class InvalidCartItem(PaymentException): pass diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 740908624c..5952668d8f 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -15,7 +15,8 @@ from django.conf import settings from django.utils.translation import ugettext as _ from mitxmako.shortcuts import render_to_string from shoppingcart.models import Order -from .exceptions import * +from shoppingcart.processors.exceptions import * + def process_postpay_callback(params): """ @@ -42,19 +43,19 @@ def process_postpay_callback(params): return {'success': False, 'order': result['order'], 'error_html': get_processor_decline_html(params)} - except CCProcessorException as e: + except CCProcessorException as error: return {'success': False, - 'order': None, #due to exception we may not have the order - 'error_html': get_processor_exception_html(e)} + 'order': None, # due to exception we may not have the order + 'error_html': get_processor_exception_html(error)} -def hash(value): +def processor_hash(value): """ Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page """ - shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') + shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET', '') hash_obj = hmac.new(shared_secret, value, sha1) - return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want + return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='orderPage_signaturePublic'): @@ -62,19 +63,19 @@ def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='order params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ - merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') - orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') - serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') + merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID', '') + order_page_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION', '7') + serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER', '') params['merchantID'] = merchant_id - params['orderPage_timestamp'] = int(time.time()*1000) - params['orderPage_version'] = orderPage_version + params['orderPage_timestamp'] = int(time.time() * 1000) + params['orderPage_version'] = order_page_version params['orderPage_serialNumber'] = serial_number fields = ",".join(params.keys()) - values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) - fields_sig = hash(fields) + values = ",".join(["{0}={1}".format(i, params[i]) for i in params.keys()]) + fields_sig = processor_hash(fields) values += ",signedFieldsPublicSignature=" + fields_sig - params[full_sig_key] = hash(values) + params[full_sig_key] = processor_hash(values) params[signed_fields_key] = fields return params @@ -90,10 +91,10 @@ def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='si """ signed_fields = params.get(signed_fields_key, '').split(',') data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) - signed_fields_sig = hash(params.get(signed_fields_key, '')) + signed_fields_sig = processor_hash(params.get(signed_fields_key, '')) data += ",signedFieldsPublicSignature=" + signed_fields_sig returned_sig = params.get(full_sig_key, '') - if hash(data) != returned_sig: + if processor_hash(data) != returned_sig: raise CCProcessorSignatureException() @@ -101,7 +102,7 @@ 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','') + purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '') total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) @@ -133,15 +134,15 @@ def payment_accepted(params): """ #make sure required keys are present and convert their values to the right type valid_params = {} - for key, type in [('orderNumber', int), - ('orderCurrency', str), - ('decision', str)]: + for key, key_type in [('orderNumber', int), + ('orderCurrency', str), + ('decision', str)]: if key not in params: raise CCProcessorDataException( _("The payment processor did not return a required parameter: {0}".format(key)) ) try: - valid_params[key] = type(params[key]) + valid_params[key] = key_type(params[key]) except ValueError: raise CCProcessorDataException( _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) @@ -170,7 +171,7 @@ def payment_accepted(params): 'order': order} else: raise CCProcessorWrongAmountException( - _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}."\ + _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}." .format(charged_amt, valid_params['orderCurrency'], order.total_cost, order.currency)) ) @@ -200,26 +201,27 @@ def record_purchase(params, order): city=params.get('billTo_city', ''), state=params.get('billTo_state', ''), country=params.get('billTo_country', ''), - postalcode=params.get('billTo_postalCode',''), + postalcode=params.get('billTo_postalCode', ''), ccnum=ccnum, cardtype=CARDTYPE_MAP[params.get('card_cardType', 'UNKNOWN')], processor_reply_dump=json.dumps(params) ) + def get_processor_decline_html(params): """Have to parse through the error codes to return a helpful message""" payment_support_email = settings.PAYMENT_SUPPORT_EMAIL msg = _(dedent( - """ -

    - Sorry! Our payment processor did not accept your payment. - The decision in they returned was {decision}, - and the reason was {reason_code}:{reason_msg}. - You were not charged. Please try a different form of payment. - Contact us with payment-specific questions at {email}. -

    - """)) + """ +

    + Sorry! Our payment processor did not accept your payment. + The decision in they returned was {decision}, + and the reason was {reason_code}:{reason_msg}. + You were not charged. Please try a different form of payment. + Contact us with payment-specific questions at {email}. +

    + """)) return msg.format( decision=params['decision'], @@ -234,43 +236,43 @@ def get_processor_exception_html(exception): payment_support_email = settings.PAYMENT_SUPPORT_EMAIL if isinstance(exception, CCProcessorDataException): msg = _(dedent( - """ -

    - Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! - We apologize that we cannot verify whether the charge went through and take further action on your order. - The specific error message is: {msg}. - Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. -

    - """.format(msg=exception.message, email=payment_support_email))) + """ +

    + Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! + We apologize that we cannot verify whether the charge went through and take further action on your order. + The specific error message is: {msg}. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) return msg elif isinstance(exception, CCProcessorWrongAmountException): msg = _(dedent( - """ -

    - Sorry! Due to an error your purchase was charged for a different amount than the order total! - The specific error message is: {msg}. - Your credit card has probably been charged. Contact us with payment-specific questions at {email}. -

    - """.format(msg=exception.message, email=payment_support_email))) + """ +

    + Sorry! Due to an error your purchase was charged for a different amount than the order total! + The specific error message is: {msg}. + Your credit card has probably been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) return msg elif isinstance(exception, CCProcessorSignatureException): msg = _(dedent( - """ -

    - Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are - unable to validate that the message actually came from the payment processor. - The specific error message is: {msg}. - We apologize that we cannot verify whether the charge went through and take further action on your order. - Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. -

    - """.format(msg=exception.message, email=payment_support_email))) + """ +

    + Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are + unable to validate that the message actually came from the payment processor. + The specific error message is: {msg}. + We apologize that we cannot verify whether the charge went through and take further action on your order. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) return msg # fallthrough case, which basically never happens return '

    EXCEPTION!

    ' -CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") +CARDTYPE_MAP = defaultdict(lambda: "UNKNOWN") CARDTYPE_MAP.update( { '001': 'Visa', @@ -294,110 +296,110 @@ CARDTYPE_MAP.update( } ) -REASONCODE_MAP = defaultdict(lambda:"UNKNOWN REASON") +REASONCODE_MAP = defaultdict(lambda: "UNKNOWN REASON") REASONCODE_MAP.update( { - '100' : _('Successful transaction.'), - '101' : _('The request is missing one or more required fields.'), - '102' : _('One or more fields in the request contains invalid data.'), - '104' : _(dedent( + '100': _('Successful transaction.'), + '101': _('The request is missing one or more required fields.'), + '102': _('One or more fields in the request contains invalid data.'), + '104': _(dedent( """ The merchantReferenceCode sent with this authorization request matches the merchantReferenceCode of another authorization request that you sent in the last 15 minutes. Possible fix: retry the payment after 15 minutes. """)), - '150' : _('Error: General system failure. Possible fix: retry the payment after a few minutes.'), - '151' : _(dedent( + '150': _('Error: General system failure. Possible fix: retry the payment after a few minutes.'), + '151': _(dedent( """ Error: The request was received but there was a server timeout. This error does not include timeouts between the client and the server. Possible fix: retry the payment after some time. """)), - '152' : _(dedent( + '152': _(dedent( """ Error: The request was received, but a service did not finish running in time Possible fix: retry the payment after some time. """)), - '201' : _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'), - '202' : _(dedent( + '201': _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'), + '202': _(dedent( """ Expired card. You might also receive this if the expiration date you provided does not match the date the issuing bank has on file. Possible fix: retry with another form of payment """)), - '203' : _(dedent( + '203': _(dedent( """ General decline of the card. No other information provided by the issuing bank. Possible fix: retry with another form of payment """)), - '204' : _('Insufficient funds in the account. Possible fix: retry with another form of payment'), + '204': _('Insufficient funds in the account. Possible fix: retry with another form of payment'), # 205 was Stolen or lost card. Might as well not show this message to the person using such a card. - '205' : _('Unknown reason'), - '207' : _('Issuing bank unavailable. Possible fix: retry again after a few minutes'), - '208' : _(dedent( + '205': _('Unknown reason'), + '207': _('Issuing bank unavailable. Possible fix: retry again after a few minutes'), + '208': _(dedent( """ Inactive card or card not authorized for card-not-present transactions. Possible fix: retry with another form of payment """)), - '210' : _('The card has reached the credit limit. Possible fix: retry with another form of payment'), - '211' : _('Invalid card verification number. Possible fix: retry with another form of payment'), + '210': _('The card has reached the credit limit. Possible fix: retry with another form of payment'), + '211': _('Invalid card verification number. Possible fix: retry with another form of payment'), # 221 was The customer matched an entry on the processor's negative file. # Might as well not show this message to the person using such a card. - '221' : _('Unknown reason'), - '231' : _('Invalid account number. Possible fix: retry with another form of payment'), - '232' : _(dedent( + '221': _('Unknown reason'), + '231': _('Invalid account number. Possible fix: retry with another form of payment'), + '232': _(dedent( """ The card type is not accepted by the payment processor. Possible fix: retry with another form of payment """)), - '233' : _('General decline by the processor. Possible fix: retry with another form of payment'), - '234' : _(dedent( + '233': _('General decline by the processor. Possible fix: retry with another form of payment'), + '234': _(dedent( """ There is a problem with our CyberSource merchant configuration. Please let us know at {0} """.format(settings.PAYMENT_SUPPORT_EMAIL))), # reason code 235 only applies if we are processing a capture through the API. so we should never see it - '235' : _('The requested amount exceeds the originally authorized amount.'), - '236' : _('Processor Failure. Possible fix: retry the payment'), + '235': _('The requested amount exceeds the originally authorized amount.'), + '236': _('Processor Failure. Possible fix: retry the payment'), # reason code 238 only applies if we are processing a capture through the API. so we should never see it - '238' : _('The authorization has already been captured'), + '238': _('The authorization has already been captured'), # reason code 239 only applies if we are processing a capture or credit through the API, # so we should never see it - '239' : _('The requested transaction amount must match the previous transaction amount.'), - '240' : _(dedent( + '239': _('The requested transaction amount must match the previous transaction amount.'), + '240': _(dedent( """ The card type sent is invalid or does not correlate with the credit card number. Possible fix: retry with the same card or another form of payment """)), # reason code 241 only applies when we are processing a capture or credit through the API, # so we should never see it - '241' : _('The request ID is invalid.'), + '241': _('The request ID is invalid.'), # reason code 242 occurs if there was not a previously successful authorization request or # if the previously successful authorization has already been used by another capture request. # This reason code only applies when we are processing a capture through the API # so we should never see it - '242' : _(dedent( + '242': _(dedent( """ You requested a capture through the API, but there is no corresponding, unused authorization record. """)), # we should never see 243 - '243' : _('The transaction has already been settled or reversed.'), + '243': _('The transaction has already been settled or reversed.'), # reason code 246 applies only if we are processing a void through the API. so we should never see it - '246' : _(dedent( + '246': _(dedent( """ The capture or credit is not voidable because the capture or credit information has already been submitted to your processor. Or, you requested a void for a type of transaction that cannot be voided. """)), # reason code 247 applies only if we are processing a void through the API. so we should never see it - '247' : _('You requested a credit for a capture that was previously voided'), - '250' : _(dedent( + '247': _('You requested a credit for a capture that was previously voided'), + '250': _(dedent( """ Error: The request was received, but there was a timeout at the payment processor. Possible fix: retry the payment. """)), - '520' : _(dedent( + '520': _(dedent( """ The authorization request was approved by the issuing bank but declined by CyberSource.' Possible fix: retry with a different form of payment. """)), } -) \ No newline at end of file +) diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index bbbbe41cde..4051d4c3ec 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -7,6 +7,7 @@ module = __import__('shoppingcart.processors.' + processor_name, 'process_postpay_callback', ]) + def render_purchase_form_html(*args, **kwargs): """ The top level call to this module to begin the purchase. @@ -16,6 +17,7 @@ def render_purchase_form_html(*args, **kwargs): """ return module.render_purchase_form_html(*args, **kwargs) + def process_postpay_callback(*args, **kwargs): """ The top level call to this module after the purchase. @@ -29,4 +31,3 @@ def process_postpay_callback(*args, **kwargs): return a helpful-enough error message in error_html. """ return module.process_postpay_callback(*args, **kwargs) - diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py index 6779ac11a6..202f143cce 100644 --- a/lms/djangoapps/shoppingcart/processors/exceptions.py +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -1,13 +1,17 @@ from shoppingcart.exceptions import PaymentException + class CCProcessorException(PaymentException): pass + class CCProcessorSignatureException(CCProcessorException): pass + class CCProcessorDataException(CCProcessorException): pass + class CCProcessorWrongAmountException(CCProcessorException): - pass \ No newline at end of file + pass diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py index df719d33b3..de9e5939f0 100644 --- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -13,15 +13,16 @@ from mock import patch, Mock TEST_CC_PROCESSOR = { - 'CyberSource' : { + 'CyberSource': { 'SHARED_SECRET': 'secret', - 'MERCHANT_ID' : 'edx_test', - 'SERIAL_NUMBER' : '12345', + 'MERCHANT_ID': 'edx_test', + 'SERIAL_NUMBER': '12345', 'ORDERPAGE_VERSION': '7', 'PURCHASE_ENDPOINT': '', } } + @override_settings(CC_PROCESSOR=TEST_CC_PROCESSOR) class CyberSourceTests(TestCase): @@ -36,8 +37,8 @@ class CyberSourceTests(TestCase): """ Tests the hash function. Basically just hardcodes the answer. """ - self.assertEqual(hash('test'), 'GqNJWF7X7L07nEhqMAZ+OVyks1Y=') - self.assertEqual(hash('edx '), '/KowheysqM2PFYuxVKg0P8Flfk4=') + self.assertEqual(processor_hash('test'), 'GqNJWF7X7L07nEhqMAZ+OVyks1Y=') + self.assertEqual(processor_hash('edx '), '/KowheysqM2PFYuxVKg0P8Flfk4=') def test_sign_then_verify(self): """ @@ -76,7 +77,7 @@ class CyberSourceTests(TestCase): """ DECISION = 'REJECT' for code, reason in REASONCODE_MAP.iteritems(): - params={ + params = { 'decision': DECISION, 'reasonCode': code, } @@ -109,8 +110,8 @@ class CyberSourceTests(TestCase): student1.save() student2 = UserFactory() student2.save() - params_cc = {'card_accountNumber':'1234', 'card_cardType':'001', 'billTo_firstName':student1.first_name} - params_nocc = {'card_accountNumber':'', 'card_cardType':'002', 'billTo_firstName':student2.first_name} + params_cc = {'card_accountNumber': '1234', 'card_cardType': '001', 'billTo_firstName': student1.first_name} + params_nocc = {'card_accountNumber': '', 'card_cardType': '002', 'billTo_firstName': student2.first_name} order1 = Order.get_cart_for_user(student1) order2 = Order.get_cart_for_user(student2) record_purchase(params_cc, order1) @@ -173,7 +174,7 @@ class CyberSourceTests(TestCase): # tests for an order number that doesn't match up params_bad_ordernum = params.copy() - params_bad_ordernum['orderNumber'] = str(order1.id+10) + params_bad_ordernum['orderNumber'] = str(order1.id + 10) with self.assertRaises(CCProcessorDataException): payment_accepted(params_bad_ordernum) @@ -215,7 +216,7 @@ class CyberSourceTests(TestCase): self.assertDictContainsSubset({'amount': '1.00', 'currency': 'usd', 'orderPage_transactionType': 'sale', - 'orderNumber':str(order1.id)}, + 'orderNumber': str(order1.id)}, context['params']) def test_process_postpay_exception(self): @@ -257,7 +258,7 @@ class CyberSourceTests(TestCase): result = process_postpay_callback(params) self.assertTrue(result['success']) self.assertEqual(result['order'], order1) - order1 = Order.objects.get(id=order1.id) # reload from DB to capture side-effect of process_postpay_callback + order1 = Order.objects.get(id=order1.id) # reload from DB to capture side-effect of process_postpay_callback self.assertEqual(order1.status, 'purchased') self.assertFalse(result['error_html']) @@ -284,4 +285,4 @@ class CyberSourceTests(TestCase): self.assertFalse(result['success']) self.assertEqual(result['order'], order1) self.assertEqual(order1.status, 'cart') - self.assertIn(REASONCODE_MAP['207'], result['error_html']) \ No newline at end of file + self.assertIn(REASONCODE_MAP['207'], result['error_html']) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index fa8345f33e..ce94ca8428 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -12,6 +12,7 @@ from .processors import process_postpay_callback, render_purchase_form_html log = logging.getLogger("shoppingcart") + def test(request, course_id): item1 = PaidCourseRegistration(course_id, 200) item1.purchased_callback(request.user.id) @@ -41,6 +42,7 @@ def register_for_verified_cert(request, course_id): CertificateItem.add_to_order(cart, course_id, 30, 'verified') return HttpResponse("Added") + @login_required def show_cart(request): cart = Order.get_cart_for_user(request.user) @@ -54,12 +56,14 @@ def show_cart(request): 'form_html': form_html, }) + @login_required def clear_cart(request): cart = Order.get_cart_for_user(request.user) cart.clear() return HttpResponse('Cleared') + @login_required def remove_item(request): item_id = request.REQUEST.get('id', '-1') @@ -71,6 +75,7 @@ def remove_item(request): log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id)) return HttpResponse('OK') + @csrf_exempt def postpay_callback(request): """ @@ -87,9 +92,10 @@ def postpay_callback(request): if result['success']: return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) else: - return render_to_response('shoppingcart/error.html', {'order':result['order'], + return render_to_response('shoppingcart/error.html', {'order': result['order'], 'error_html': result['error_html']}) + @login_required def show_receipt(request, ordernum): """ From 42f8b970fccbff10b4b416af1e67cbefed83250a Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 11:10:47 -0400 Subject: [PATCH 029/185] Clean up views and models. --- lms/djangoapps/shoppingcart/models.py | 16 +++++++++++++++- lms/djangoapps/shoppingcart/views.py | 9 +++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 895f466273..a738dd2107 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -6,7 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User from django.utils.translation import ugettext as _ from model_utils.managers import InheritanceManager -from courseware.courses import course_image_url, get_course_about_section +from courseware.courses import get_course_about_section from xmodule.modulestore.django import modulestore from xmodule.course_module import CourseDescriptor @@ -66,6 +66,7 @@ class Order(models.Model): @property def total_cost(self): + """ Return the total cost of the order """ return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status)) def clear(self): @@ -79,6 +80,19 @@ class Order(models.Model): """ Call to mark this order as purchased. Iterates through its OrderItems and calls their purchased_callback + + `first` - first name of person billed (e.g. John) + `last` - last name of person billed (e.g. Smith) + `street1` - first line of a street address of the billing address (e.g. 11 Cambridge Center) + `street2` - second line of a street address of the billing address (e.g. Suite 101) + `city` - city of the billing address (e.g. Cambridge) + `state` - code of the state, province, or territory of the billing address (e.g. MA) + `postalcode` - postal code of the billing address (e.g. 02142) + `country` - country code of the billing address (e.g. US) + `ccnum` - last 4 digits of the credit card number of the credit card billed (e.g. 1111) + `cardtype` - 3-digit code representing the card type used (e.g. 001) + `processor_reply_dump` - all the parameters returned by the processor + """ self.status = 'purchased' self.purchase_time = datetime.now(pytz.utc) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index ce94ca8428..8e56971d47 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,13 +1,14 @@ import logging from django.http import HttpResponse, HttpResponseRedirect, HttpResponseNotFound, HttpResponseForbidden, Http404 from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_POST from django.core.urlresolvers import reverse from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required from student.models import CourseEnrollment from xmodule.modulestore.exceptions import ItemNotFoundError from mitxmako.shortcuts import render_to_response -from .models import * +from .models import Order, PaidCourseRegistration, CertificateItem, OrderItem from .processors import process_postpay_callback, render_purchase_form_html log = logging.getLogger("shoppingcart") @@ -38,6 +39,9 @@ def add_course_to_cart(request, course_id): @login_required def register_for_verified_cert(request, course_id): + """ + Add a CertificateItem to the cart + """ cart = Order.get_cart_for_user(request.user) CertificateItem.add_to_order(cart, course_id, 30, 'verified') return HttpResponse("Added") @@ -77,6 +81,7 @@ def remove_item(request): @csrf_exempt +@require_POST def postpay_callback(request): """ Receives the POST-back from processor. @@ -111,7 +116,7 @@ def show_receipt(request, ordernum): raise Http404('Order not found!') order_items = order.orderitem_set.all() - any_refunds = "refunded" in [i.status for i in order_items] + 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}) From 1c2d84077bf967bcfdc7d998c8e7fe969b343fe4 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 09:58:13 -0700 Subject: [PATCH 030/185] 100% coverage on shoppingcart/models.py --- lms/djangoapps/shoppingcart/models.py | 10 +++- lms/djangoapps/shoppingcart/tests.py | 82 ++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index a738dd2107..69ae0311a4 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -220,11 +220,17 @@ class PaidCourseRegistration(OrderItem): in CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment would in fact be quite silly since there's a clear back door. """ - course_loc = CourseDescriptor.id_to_location(self.course_id) - course_exists = modulestore().has_item(self.course_id, course_loc) + try: + course_loc = CourseDescriptor.id_to_location(self.course_id) + course_exists = modulestore().has_item(self.course_id, course_loc) + except ValueError: + raise PurchasedCallbackException( + "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) + if not course_exists: raise PurchasedCallbackException( "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) + CourseEnrollment.enroll(user=self.user, course_id=self.course_id, mode=self.mode) log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 61a10f2f75..5754d2173d 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -4,9 +4,15 @@ Tests for the Shopping Cart from factory import DjangoModelFactory from django.test import TestCase -from shoppingcart.models import Order, CertificateItem, InvalidCartItem +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 +from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration from student.tests.factories import UserFactory from student.models import CourseEnrollment +from course_modes.models import CourseMode +from .exceptions import PurchasedCallbackException class OrderTest(TestCase): @@ -69,6 +75,80 @@ class OrderTest(TestCase): self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) +class OrderItemTest(TestCase): + def setUp(self): + self.user = UserFactory.create() + + def test_orderItem_purchased_callback(self): + """ + This tests that calling purchased_callback on the base OrderItem class raises NotImplementedError + """ + item = OrderItem(user=self.user, order=Order.get_cart_for_user(self.user)) + with self.assertRaises(NotImplementedError): + item.purchased_callback() + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class PaidCourseRegistrationTest(ModuleStoreTestCase): + def setUp(self): + self.user = UserFactory.create() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.cart = Order.get_cart_for_user(self.user) + + def test_add_to_order(self): + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + + self.assertEqual(reg1.unit_cost, self.cost) + self.assertEqual(reg1.line_cost, self.cost) + self.assertEqual(reg1.unit_cost, self.course_mode.min_price) + self.assertEqual(reg1.mode, "honor") + self.assertEqual(reg1.user, self.user) + self.assertEqual(reg1.status, "cart") + self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) + self.assertFalse(PaidCourseRegistration.part_of_order(self.cart, self.course_id+"abcd")) + self.assertEqual(self.cart.total_cost, self.cost) + + def test_add_with_default_mode(self): + """ + Tests add_to_cart where the mode specified in the argument is NOT in the database + and NOT the default "honor". In this case it just adds the user in the CourseMode.DEFAULT_MODE, 0 price + """ + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id, mode_slug="DNE") + + self.assertEqual(reg1.unit_cost, 0) + self.assertEqual(reg1.line_cost, 0) + self.assertEqual(reg1.mode, "honor") + self.assertEqual(reg1.user, self.user) + self.assertEqual(reg1.status, "cart") + self.assertEqual(self.cart.total_cost, 0) + self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) + + def test_purchased_callback(self): + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + reg1.purchased_callback() + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + def test_purchased_callback_exception(self): + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + reg1.course_id = "changedforsomereason" + reg1.save() + with self.assertRaises(PurchasedCallbackException): + reg1.purchased_callback() + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + reg1.course_id = "abc/efg/hij" + reg1.save() + with self.assertRaises(PurchasedCallbackException): + reg1.purchased_callback() + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + class CertificateItemTest(TestCase): """ Tests for verifying specific CertificateItem functionality From f59e7edb8f446d72ef3d5317ca5eedd7844d3710 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 10:06:14 -0700 Subject: [PATCH 031/185] minor changes to PaidCourseRegistrationTest.test_purchased_callback --- lms/djangoapps/shoppingcart/tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 5754d2173d..10b59deee6 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -131,8 +131,10 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): def test_purchased_callback(self): reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) - reg1.purchased_callback() + self.cart.purchase() self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + reg1 = PaidCourseRegistration.objects.get(id=reg1.id) # reload from DB to get side-effect + self.assertEqual(reg1.status, "purchased") def test_purchased_callback_exception(self): reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) From ef4e8f7e13e33164c07b4fdb6247cd175bfe3539 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 10:31:18 -0700 Subject: [PATCH 032/185] move currency formatting into template --- lms/djangoapps/shoppingcart/views.py | 3 +-- lms/templates/shoppingcart/list.html | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 8e56971d47..be363f1422 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -51,12 +51,11 @@ def register_for_verified_cert(request, course_id): def show_cart(request): cart = Order.get_cart_for_user(request.user) total_cost = cart.total_cost - amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() form_html = render_purchase_form_html(cart) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, - 'amount': amount, + 'amount': total_cost, 'form_html': form_html, }) diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 0754cac311..cf452baab0 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -21,7 +21,7 @@ [x] % endfor ${_("Total Amount")} - ${amount} + ${"{0:0.2f}".format(amount)} From 26f1caaf57023e89ea15ae35a54ed6741d46d152 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 13:53:58 -0400 Subject: [PATCH 033/185] Add jsinput_spec back in. --- common/static/js/capa/spec/jsinput_spec.js | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 common/static/js/capa/spec/jsinput_spec.js diff --git a/common/static/js/capa/spec/jsinput_spec.js b/common/static/js/capa/spec/jsinput_spec.js new file mode 100644 index 0000000000..a4a4f6e57d --- /dev/null +++ b/common/static/js/capa/spec/jsinput_spec.js @@ -0,0 +1,70 @@ +xdescribe("A jsinput has:", function () { + + beforeEach(function () { + $('#fixture').remove(); + $.ajax({ + async: false, + url: 'mainfixture.html', + success: function(data) { + $('body').append($(data)); + } + }); + }); + + + + describe("The jsinput constructor", function(){ + + var iframe1 = $(document).find('iframe')[0]; + + var testJsElem = jsinputConstructor({ + id: 1, + elem: iframe1, + passive: false + }); + + it("Returns an object", function(){ + expect(typeof(testJsElem)).toEqual('object'); + }); + + it("Adds the object to the jsinput array", function() { + expect(jsinput.exists(1)).toBe(true); + }); + + describe("The returned object", function() { + + it("Has a public 'update' method", function(){ + expect(testJsElem.update).toBeDefined(); + }); + + it("Returns an 'update' that is idempotent", function(){ + var orig = testJsElem.update(); + for (var i = 0; i++; i < 5) { + expect(testJsElem.update()).toEqual(orig); + } + }); + + it("Changes the parent's inputfield", function() { + testJsElem.update(); + + }); + }); + }); + + + describe("The walkDOM functions", function() { + + walkDOM(); + + it("Creates (at least) one object per iframe", function() { + jsinput.arr.length >= 2; + }); + + it("Does not create multiple objects with the same id", function() { + while (jsinput.arr.length > 0) { + var elem = jsinput.arr.pop(); + expect(jsinput.exists(elem.id)).toBe(false); + } + }); + }); +}) From e2ae0b29708931650fa2e11e3c726c792974338a Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 14:49:18 -0400 Subject: [PATCH 034/185] Remove line_cost from OrderItem --- ...003_auto__del_field_orderitem_line_cost.py | 113 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 9 +- lms/djangoapps/shoppingcart/tests.py | 5 +- 3 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py new file mode 100644 index 0000000000..8402248aae --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py @@ -0,0 +1,113 @@ +# -*- 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): + # Deleting field 'OrderItem.line_cost' + db.delete_column('shoppingcart_orderitem', 'line_cost') + + + def backwards(self, orm): + # Adding field 'OrderItem.line_cost' + db.add_column('shoppingcart_orderitem', 'line_cost', + self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2), + keep_default=False) + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 69ae0311a4..4387a8352c 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -133,14 +133,19 @@ class OrderItem(models.Model): status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) qty = models.IntegerField(default=1) unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) - line_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) # qty * unit_cost line_desc = models.CharField(default="Misc. Item", max_length=1024) currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes + @property + def line_cost(self): + return self.qty * self.unit_cost + @classmethod def add_to_order(cls, *args, **kwargs): """ A suggested convenience function for subclasses. + + NOTE: This does not add anything items to the cart. That is left up to the subclasses """ # this is a validation step to verify that the currency of the item we # are adding is the same as the currency of the order we are adding it @@ -204,7 +209,6 @@ class PaidCourseRegistration(OrderItem): item.mode = course_mode.slug item.qty = 1 item.unit_cost = cost - item.line_cost = cost item.line_desc = 'Registration for Course: {0}. Mode: {1}'.format(get_course_about_section(course, "title"), course_mode.name) item.currency = currency @@ -283,7 +287,6 @@ class CertificateItem(OrderItem): item.status = order.status item.qty = 1 item.unit_cost = cost - item.line_cost = cost item.line_desc = "{mode} certificate for course {course_id}".format(mode=item.mode, course_id=course_id) item.currency = currency diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 10b59deee6..39d0f0b301 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -87,6 +87,7 @@ class OrderItemTest(TestCase): with self.assertRaises(NotImplementedError): item.purchased_callback() + @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class PaidCourseRegistrationTest(ModuleStoreTestCase): def setUp(self): @@ -111,7 +112,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): self.assertEqual(reg1.user, self.user) self.assertEqual(reg1.status, "cart") self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) - self.assertFalse(PaidCourseRegistration.part_of_order(self.cart, self.course_id+"abcd")) + self.assertFalse(PaidCourseRegistration.part_of_order(self.cart, self.course_id + "abcd")) self.assertEqual(self.cart.total_cost, self.cost) def test_add_with_default_mode(self): @@ -133,7 +134,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) self.cart.purchase() self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) - reg1 = PaidCourseRegistration.objects.get(id=reg1.id) # reload from DB to get side-effect + reg1 = PaidCourseRegistration.objects.get(id=reg1.id) # reload from DB to get side-effect self.assertEqual(reg1.status, "purchased") def test_purchased_callback_exception(self): From abd678e46476b199c4b82d6138a81afa08139e43 Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Wed, 21 Aug 2013 15:26:14 -0400 Subject: [PATCH 035/185] initial step interaction for verification --- .../responsive-carousel.js | 4 ++ .../responsive-carousel.keybd.js | 38 ++++++++++++ lms/static/sass/application.scss.mako | 15 +++-- .../_responsive-carousel.scss | 20 ++++++ .../_responsive-carousel.slide.scss | 61 +++++++++++++++++++ lms/static/sass/views/_verification.scss | 6 +- lms/templates/main.html | 3 + lms/templates/verify_student/face_upload.html | 14 ++++- 8 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 lms/static/js/vendor/responsive-carousel/responsive-carousel.js create mode 100644 lms/static/js/vendor/responsive-carousel/responsive-carousel.keybd.js create mode 100644 lms/static/sass/vendor/responsive-carousel/_responsive-carousel.scss create mode 100644 lms/static/sass/vendor/responsive-carousel/_responsive-carousel.slide.scss 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/sass/application.scss.mako b/lms/static/sass/application.scss.mako index fb7b4b1db2..c17b23b0dc 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,18 @@ // 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'; + +// base - specific views +@import 'views/verification'; + +// shared - course @import 'shared/forms'; @import 'shared/footer'; @import 'shared/header'; @@ -67,7 +74,7 @@ @import 'multicourse/help'; @import 'multicourse/edge'; -// applications +// applications @import 'discussion'; @import 'news'; 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 index a0eebf0fea..8da7a8d69e 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -41,13 +41,9 @@ body.register.verification { } } - - #wrapper { - overflow: hidden; - } - .block-photo { @include clearfix(); + background-color: $white; .title { font-weight: bold; diff --git a/lms/templates/main.html b/lms/templates/main.html index a25e8f5261..179a84f7d3 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -92,6 +92,9 @@ <%static:js group='application'/> <%static:js group='module-js'/> + + + <%block name="js_extra"/> diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html index 2010ec53fe..ccf0e76bfa 100644 --- a/lms/templates/verify_student/face_upload.html +++ b/lms/templates/verify_student/face_upload.html @@ -4,8 +4,16 @@ <%block name="bodyclass">register verification <%block name="js_extra"> + @@ -30,7 +38,7 @@ -
      +
    2. From bd4cc448af1222fb39790b5f52d917ca1dab9f5f Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 15:18:21 -0400 Subject: [PATCH 037/185] Clean up models, add some error handling --- common/djangoapps/course_modes/models.py | 2 +- .../shoppingcart/migrations/0001_initial.py | 4 +--- ...__add_field_paidcourseregistration_mode.py | 4 +--- ...003_auto__del_field_orderitem_line_cost.py | 4 +--- lms/djangoapps/shoppingcart/models.py | 19 +++++++++++-------- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 6362b7061f..7a5e711f44 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -62,7 +62,7 @@ class CourseMode(models.Model): """ modes = cls.modes_for_course(course_id) - matched = filter(lambda m: m.slug == mode_slug, modes) + matched = [m for m in modes if m.slug == mode_slug] if matched: return matched[0] else: diff --git a/lms/djangoapps/shoppingcart/migrations/0001_initial.py b/lms/djangoapps/shoppingcart/migrations/0001_initial.py index ea6a250f77..24ffeb1e59 100644 --- a/lms/djangoapps/shoppingcart/migrations/0001_initial.py +++ b/lms/djangoapps/shoppingcart/migrations/0001_initial.py @@ -59,7 +59,6 @@ class Migration(SchemaMigration): )) db.send_create_signal('shoppingcart', ['CertificateItem']) - def backwards(self, orm): # Deleting model 'Order' db.delete_table('shoppingcart_order') @@ -73,7 +72,6 @@ class Migration(SchemaMigration): # Deleting model 'CertificateItem' db.delete_table('shoppingcart_certificateitem') - models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, @@ -165,4 +163,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['shoppingcart'] \ No newline at end of file + complete_apps = ['shoppingcart'] diff --git a/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py index 1a6730c769..97f46aee81 100644 --- a/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py +++ b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py @@ -13,12 +13,10 @@ class Migration(SchemaMigration): self.gf('django.db.models.fields.SlugField')(default='honor', max_length=50), keep_default=False) - def backwards(self, orm): # Deleting field 'PaidCourseRegistration.mode' db.delete_column('shoppingcart_paidcourseregistration', 'mode') - models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, @@ -111,4 +109,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['shoppingcart'] \ No newline at end of file + complete_apps = ['shoppingcart'] diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py index 8402248aae..080a6f1af2 100644 --- a/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py @@ -11,14 +11,12 @@ class Migration(SchemaMigration): # Deleting field 'OrderItem.line_cost' db.delete_column('shoppingcart_orderitem', 'line_cost') - def backwards(self, orm): # Adding field 'OrderItem.line_cost' db.add_column('shoppingcart_orderitem', 'line_cost', self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2), keep_default=False) - models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, @@ -110,4 +108,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['shoppingcart'] \ No newline at end of file + complete_apps = ['shoppingcart'] diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 4387a8352c..415a9ebe50 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -5,6 +5,7 @@ from django.db import models from django.core.exceptions import ObjectDoesNotExist 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 @@ -113,8 +114,8 @@ class Order(models.Model): orderitems = OrderItem.objects.filter(order=self).select_subclasses() for item in orderitems: item.status = 'purchased' - item.purchased_callback() item.save() + item.purchased_callback() class OrderItem(models.Model): @@ -138,23 +139,23 @@ class OrderItem(models.Model): @property def line_cost(self): + """ Return the total cost of this OrderItem """ return self.qty * self.unit_cost @classmethod - def add_to_order(cls, *args, **kwargs): + def add_to_order(cls, order, *args, **kwargs): """ A suggested convenience function for subclasses. - NOTE: This does not add anything items to the cart. That is left up to the subclasses + NOTE: This does not add anything to the cart. That is left up to the + subclasses to implement for themselves """ # this is a validation step to verify that the currency of the item we # are adding is the same as the currency of the order we are adding it # to - if isinstance(args[0], Order): - currency = kwargs['currency'] if 'currency' in kwargs else 'usd' - order = args[0] - if order.currency != currency and order.orderitem_set.count() > 0: - raise InvalidCartItem(_("Trying to add a different currency into the cart")) + currency = kwargs.get('currency', 'usd') + if order.currency != currency and order.orderitem_set.exists(): + raise InvalidCartItem(_("Trying to add a different currency into the cart")) def purchased_callback(self): """ @@ -180,6 +181,7 @@ class PaidCourseRegistration(OrderItem): for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")] @classmethod + @transaction.commit_on_success def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): """ A standardized way to create these objects, with sensible defaults filled in. @@ -254,6 +256,7 @@ class CertificateItem(OrderItem): mode = models.SlugField() @classmethod + @transaction.commit_on_success def add_to_order(cls, order, course_id, cost, mode, currency='usd'): """ Add a CertificateItem to an order From 0db7884354e2dbc0dc4aee97111554ec7791ab40 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 9 Aug 2013 12:14:29 -0400 Subject: [PATCH 038/185] Basic first commit of Photo ID Verification model and test code --- lms/djangoapps/verify_student/__init__.py | 0 lms/djangoapps/verify_student/api.py | 0 .../verify_student/migrations/__init__.py | 0 lms/djangoapps/verify_student/models.py | 322 ++++++++++++++++++ .../verify_student/tests/__init__.py | 0 .../verify_student/tests/test_models.py | 59 ++++ .../verify_student/tests/test_views.py | 37 ++ lms/djangoapps/verify_student/urls.py | 0 lms/djangoapps/verify_student/views.py | 13 + lms/envs/common.py | 3 + requirements/edx/base.txt | 1 + 11 files changed, 435 insertions(+) create mode 100644 lms/djangoapps/verify_student/__init__.py create mode 100644 lms/djangoapps/verify_student/api.py create mode 100644 lms/djangoapps/verify_student/migrations/__init__.py create mode 100644 lms/djangoapps/verify_student/models.py create mode 100644 lms/djangoapps/verify_student/tests/__init__.py create mode 100644 lms/djangoapps/verify_student/tests/test_models.py create mode 100644 lms/djangoapps/verify_student/tests/test_views.py create mode 100644 lms/djangoapps/verify_student/urls.py create mode 100644 lms/djangoapps/verify_student/views.py 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/__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..852bc4a50f --- /dev/null +++ b/lms/djangoapps/verify_student/models.py @@ -0,0 +1,322 @@ +# -*- coding: utf-8 -*- +""" +Models for Student Identity Verification + +Currently the only model is `PhotoVerificationAttempt`, but this is where we +would put any models relating to establishing the real-life identity of a +student over a period of time. +""" +from datetime import datetime +import functools +import logging +import uuid + +import pytz +from django.db import models +from django.contrib.auth.models import User + +from model_utils.models import StatusModel +from model_utils import Choices + +log = logging.getLogger(__name__) + + +class VerificationException(Exception): + pass + + +class IdVerifiedCourses(models.Model): + """ + A table holding all the courses that are eligible for ID Verification. + """ + course_id = models.CharField(blank=False, max_length=100) + + +def status_before_must_be(*valid_start_statuses): + """ + 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 PhotoVerificationAttempt(StatusModel): + """ + Each PhotoVerificationAttempt 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 create and edit a + `PhotoVerificationAttempt` object through the methods provided**. Do not + just construct one and start setting fields unless you really know what + you're doing. + + 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 == PhotoVerificationAttempt.STATUS.created + attempt.status == "created" + pending_requests = PhotoVerificationAttempt.submitted.all() + """ + ######################## 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) + + + ##### Methods listed in the order you'd typically call them + @classmethod + def user_is_verified(cls, user_id): + """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.""" + raise NotImplementedError + + + @classmethod + def active_for_user(cls, user_id): + """Return all PhotoVerificationAttempts 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? + """ + raise NotImplementedError + + + @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() + + 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..2c80447d6c --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -0,0 +1,59 @@ +# -*- 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 PhotoVerificationAttempt, VerificationException + + +class TestPhotoVerificationAttempt(object): + + 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 = PhotoVerificationAttempt(user=user) + assert_equals(attempt.status, PhotoVerificationAttempt.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_views.py b/lms/djangoapps/verify_student/tests/test_views.py new file mode 100644 index 0000000000..47b08f7b35 --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -0,0 +1,37 @@ +""" + + +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/start?course_id={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())) + + \ No newline at end of file diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py new file mode 100644 index 0000000000..964f8fa0f3 --- /dev/null +++ b/lms/djangoapps/verify_student/views.py @@ -0,0 +1,13 @@ +""" + + +""" + +@login_required +def start(request): + """ + If they've already started a PhotoVerificationAttempt, we move to wherever + they are in that process. If they've completed one, then we skip straight + to payment. + """ + \ No newline at end of file diff --git a/lms/envs/common.py b/lms/envs/common.py index 250552a40c..d4ff040d5f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -775,6 +775,9 @@ INSTALLED_APPS = ( # Different Course Modes 'course_modes' + + # Student Identity Verification + 'verify_student', ) ######################### MARKETING SITE ############################### diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9179315797..070f0a060d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -12,6 +12,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 From 086f55643eaccb2a6548fa300bbedbbda7a48da1 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 15 Aug 2013 14:20:36 -0400 Subject: [PATCH 039/185] Bare bones outline of ID verification templates --- lms/djangoapps/verify_student/urls.py | 28 +++++++++++++++++++ lms/djangoapps/verify_student/views.py | 24 ++++++++++++++-- lms/envs/common.py | 5 +++- lms/envs/dev.py | 1 + lms/templates/courseware/course_about.html | 2 ++ lms/templates/verify_student/face_upload.html | 11 ++++++++ .../verify_student/final_verification.html | 10 +++++++ .../verify_student/photo_id_upload.html | 11 ++++++++ .../verify_student/show_requirements.html | 12 ++++++++ lms/urls.py | 6 ++++ 10 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 lms/templates/verify_student/face_upload.html create mode 100644 lms/templates/verify_student/final_verification.html create mode 100644 lms/templates/verify_student/photo_id_upload.html create mode 100644 lms/templates/verify_student/show_requirements.html diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index e69de29bb2..a3644615e8 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -0,0 +1,28 @@ +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', + views.show_requirements, + name="verify_student/show_requirements" + ), + url( + r'^face_upload', + views.face_upload, + name="verify_student/face_upload" + ), + url( + r'^photo_id_upload', + views.photo_id_upload, + name="verify_student/photo_id_upload" + ), + url( + r'^final_verification', + views.final_verification, + name="verify_student/final_verification" + ), +) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 964f8fa0f3..acaafb092d 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -2,12 +2,30 @@ """ +from mitxmako.shortcuts import render_to_response -@login_required -def start(request): +# @login_required +def start_or_resume_attempt(request): """ If they've already started a PhotoVerificationAttempt, we move to wherever they are in that process. If they've completed one, then we skip straight to payment. """ - \ No newline at end of file + pass + +def show_requirements(request): + """This might just be a plain template without a view.""" + context = { "course_id" : "edX/Certs101/2013_Test" } + return render_to_response("verify_student/show_requirements.html", context) + +def face_upload(request): + context = { "course_id" : "edX/Certs101/2013_Test" } + return render_to_response("verify_student/face_upload.html", context) + +def photo_id_upload(request): + context = { "course_id" : "edX/Certs101/2013_Test" } + return render_to_response("verify_student/photo_id_upload.html", context) + +def final_verification(request): + context = { "course_id" : "edX/Certs101/2013_Test" } + return render_to_response("verify_student/final_verification.html", context) diff --git a/lms/envs/common.py b/lms/envs/common.py index d4ff040d5f..22923de539 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -154,6 +154,9 @@ MITX_FEATURES = { # Toggle to enable chat availability (configured on a per-course # basis in Studio) 'ENABLE_CHAT': False, + + # Allow users to enroll with methods other than just honor code certificates + 'MULTIPLE_ENROLLMENT_ROLES' : False } # Used for A/B testing @@ -774,7 +777,7 @@ INSTALLED_APPS = ( 'notification_prefs', # Different Course Modes - 'course_modes' + 'course_modes', # Student Identity Verification 'verify_student', diff --git a/lms/envs/dev.py b/lms/envs/dev.py index d47c7bf82d..4d90c2a816 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -30,6 +30,7 @@ MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com" diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index e4a453133d..39cf04e72c 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -96,6 +96,8 @@ %else: ${_("Register for {course.display_number_with_default}").format(course=course) | h} + Mock Verify Enrollment +
      %endif
      diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html new file mode 100644 index 0000000000..6338750c06 --- /dev/null +++ b/lms/templates/verify_student/face_upload.html @@ -0,0 +1,11 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="content"> + +

      Face Upload!

      + +Upload Photo ID + + 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..b6724656f4 --- /dev/null +++ b/lms/templates/verify_student/photo_id_upload.html @@ -0,0 +1,11 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="content"> + +

      Photo ID Upload!

      + +Final Verification + + diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html new file mode 100644 index 0000000000..5fa00a0145 --- /dev/null +++ b/lms/templates/verify_student/show_requirements.html @@ -0,0 +1,12 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="content"> + +

      Requirements Page!

      + +Upload Face + + + diff --git a/lms/urls.py b/lms/urls.py index b32c0263d0..58c2cd3b55 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -61,8 +61,13 @@ 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')),) + + js_info_dict = { 'domain': 'djangojs', 'packages': ('lms',), @@ -340,6 +345,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$', From 8f572e537e777a8f8e0d6e7ab33593b1b2afc30d Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 19 Aug 2013 11:02:08 -0400 Subject: [PATCH 040/185] Flesh out the Software Secure model and add SS-specific encryption functions --- lms/djangoapps/verify_student/models.py | 107 +++++++++++++----- lms/djangoapps/verify_student/ssencrypt.py | 90 +++++++++++++++ .../verify_student/tests/test_ssencrypt.py | 83 ++++++++++++++ 3 files changed, 251 insertions(+), 29 deletions(-) create mode 100644 lms/djangoapps/verify_student/ssencrypt.py create mode 100644 lms/djangoapps/verify_student/tests/test_ssencrypt.py diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 852bc4a50f..913d69f06b 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -1,23 +1,31 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- """ Models for Student Identity Verification -Currently the only model is `PhotoVerificationAttempt`, but this is where we -would put any models relating to establishing the real-life identity of a -student over a period of time. +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 +`PhotoVerificationAttempt`, and its one concrete implementation +`SoftwareSecurePhotoVerificationAttempt`. The hope is to keep as much of the +photo verification process as generic as possible. """ from datetime import datetime +from hashlib import md5 +import base64 import functools import logging import uuid import pytz + 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__) @@ -25,16 +33,9 @@ class VerificationException(Exception): pass -class IdVerifiedCourses(models.Model): - """ - A table holding all the courses that are eligible for ID Verification. - """ - course_id = models.CharField(blank=False, max_length=100) - - def status_before_must_be(*valid_start_statuses): """ - Decorator with arguments to make sure that an object with a `status` + 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: @@ -68,7 +69,7 @@ class PhotoVerificationAttempt(StatusModel): 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 create and edit a + for the querying facilities, **you should only create and edit a `PhotoVerificationAttempt` object through the methods provided**. Do not just construct one and start setting fields unless you really know what you're doing. @@ -89,12 +90,12 @@ class PhotoVerificationAttempt(StatusModel): 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 + 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 == PhotoVerificationAttempt.STATUS.created attempt.status == "created" pending_requests = PhotoVerificationAttempt.submitted.all() @@ -126,11 +127,9 @@ class PhotoVerificationAttempt(StatusModel): 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( @@ -153,6 +152,8 @@ class PhotoVerificationAttempt(StatusModel): # 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 ##### Methods listed in the order you'd typically call them @classmethod @@ -162,7 +163,6 @@ class PhotoVerificationAttempt(StatusModel): time, so a user might have to renew periodically.""" raise NotImplementedError - @classmethod def active_for_user(cls, user_id): """Return all PhotoVerificationAttempts that are still active (i.e. not @@ -173,17 +173,14 @@ class PhotoVerificationAttempt(StatusModel): """ raise NotImplementedError - @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): """ @@ -215,7 +212,7 @@ class PhotoVerificationAttempt(StatusModel): 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. @@ -223,7 +220,6 @@ class PhotoVerificationAttempt(StatusModel): self.status = "ready" self.save() - @status_before_must_be("ready", "submit") def submit(self, reviewing_service=None): if self.status == "submitted": @@ -235,7 +231,6 @@ class PhotoVerificationAttempt(StatusModel): self.status = "submitted" self.save() - @status_before_must_be("submitted", "approved", "denied") def approve(self, user_id=None, service=""): """ @@ -268,15 +263,14 @@ class PhotoVerificationAttempt(StatusModel): # 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.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, @@ -292,7 +286,8 @@ class PhotoVerificationAttempt(StatusModel): 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` + `reviewed_by_user_id`, `reviewed_by_service`, `error_msg`, + `error_code` State Transitions: @@ -320,3 +315,57 @@ class PhotoVerificationAttempt(StatusModel): self.save() +class SoftwareSecurePhotoVerificationAttempt(PhotoVerificationAttempt): + """ + Model to verify identity using a service provided by Software Secure. Much + of the logic is inherited from `PhotoVerificationAttempt`, 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/test_ssencrypt.py b/lms/djangoapps/verify_student/tests/test_ssencrypt.py new file mode 100644 index 0000000000..f2d063daf4 --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_ssencrypt.py @@ -0,0 +1,83 @@ +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 +) + +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) + + # Software Secure would decrypt our photo_id image by doing: + #rsa_encrypted_aes_key = base64.urlsafe_b64decode(encoded_photo_id_key) + #photo_id_aes_key = rsa_decrypt(rsa_encrypted_aes_key, priv_key_str) + From 775ae010a23a1a94bc96ab0e4e62b787f3d2502e Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Tue, 20 Aug 2013 09:19:35 -0400 Subject: [PATCH 041/185] Shorted names of models in verify_student --- lms/djangoapps/verify_student/models.py | 38 +++++++++++-------- .../verify_student/tests/test_models.py | 19 +++++----- lms/djangoapps/verify_student/urls.py | 15 ++++++++ lms/djangoapps/verify_student/views.py | 22 ++++++++++- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 913d69f06b..816f5da8d4 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -4,8 +4,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 -`PhotoVerificationAttempt`, and its one concrete implementation -`SoftwareSecurePhotoVerificationAttempt`. The hope is to keep as much of the +`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 @@ -63,14 +63,14 @@ def status_before_must_be(*valid_start_statuses): return decorator_func -class PhotoVerificationAttempt(StatusModel): +class PhotoVerification(StatusModel): """ - Each PhotoVerificationAttempt represents a Student's attempt to establish + 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 create and edit a - `PhotoVerificationAttempt` object through the methods provided**. Do not + `PhotoVerification` object through the methods provided**. Do not just construct one and start setting fields unless you really know what you're doing. @@ -96,9 +96,9 @@ class PhotoVerificationAttempt(StatusModel): Because this Model inherits from StatusModel, we can also do things like:: - attempt.status == PhotoVerificationAttempt.STATUS.created + attempt.status == PhotoVerification.STATUS.created attempt.status == "created" - pending_requests = PhotoVerificationAttempt.submitted.all() + pending_requests = PhotoVerification.submitted.all() """ ######################## Fields Set During Creation ######################## # See class docstring for description of status states @@ -154,24 +154,33 @@ class PhotoVerificationAttempt(StatusModel): class Meta: abstract = True + ordering = ['-created_at'] ##### Methods listed in the order you'd typically call them @classmethod - def user_is_verified(cls, user_id): - """Returns whether or not a user has satisfactorily proved their + def user_is_verified(cls, user): + """ + 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.""" raise NotImplementedError @classmethod - def active_for_user(cls, user_id): - """Return all PhotoVerificationAttempts that are still active (i.e. not + 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? """ - raise NotImplementedError + # 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): @@ -315,10 +324,10 @@ class PhotoVerificationAttempt(StatusModel): self.save() -class SoftwareSecurePhotoVerificationAttempt(PhotoVerificationAttempt): +class SoftwareSecurePhotoVerification(PhotoVerification): """ Model to verify identity using a service provided by Software Secure. Much - of the logic is inherited from `PhotoVerificationAttempt`, but this class + of the logic is inherited from `PhotoVerification`, but this class encrypts the photos. Software Secure (http://www.softwaresecure.com/) is a remote proctoring @@ -368,4 +377,3 @@ class SoftwareSecurePhotoVerificationAttempt(PhotoVerificationAttempt): ) rsa_cipher = PKCS1_OAEP.new(key) rsa_encrypted_aes_key = rsa_cipher.encrypt(aes_key) - diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index 2c80447d6c..838da26fba 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -1,12 +1,13 @@ -# -*- coding: utf-8 -*- -from nose.tools import assert_in, assert_is_none, assert_equals, \ - assert_raises, assert_not_equals +# -*- 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 PhotoVerificationAttempt, VerificationException +from verify_student.models import SoftwareSecurePhotoVerification, VerificationException -class TestPhotoVerificationAttempt(object): +class TestPhotoVerification(object): def test_state_transitions(self): """Make sure we can't make unexpected status transitions. @@ -14,12 +15,12 @@ class TestPhotoVerificationAttempt(object): The status transitions we expect are:: created → ready → submitted → approved - ↑ ↓ + ↑ ↓ → denied """ user = UserFactory.create() - attempt = PhotoVerificationAttempt(user=user) - assert_equals(attempt.status, PhotoVerificationAttempt.STATUS.created) + 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 @@ -38,7 +39,7 @@ class TestPhotoVerificationAttempt(object): 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. + # 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. diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index a3644615e8..de08563970 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -25,4 +25,19 @@ urlpatterns = patterns( views.final_verification, name="verify_student/final_verification" ), + + # The above are what we did for the design mockups, but what we're really + # looking at now is: + url( + r'^show_verification_page', + views.show_verification_page, + name="verify_student/show_verification_page" + ), + + url( + r'^start_or_resume_attempt', + views.start_or_resume_attempt, + name="verify_student/start_or_resume_attempt" + ) + ) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index acaafb092d..3da2dd3bbe 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -4,14 +4,26 @@ """ from mitxmako.shortcuts import render_to_response +from verify_student.models import SoftwareSecurePhotoVerification + # @login_required -def start_or_resume_attempt(request): +def start_or_resume_attempt(request, course_id): """ If they've already started a PhotoVerificationAttempt, we move to wherever they are in that process. If they've completed one, then we skip straight to payment. """ - pass + # 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_is_verified(user): + # pass + + attempt = SoftwareSecurePhotoVerification.active_for_user(request.user) + if not attempt: + # Redirect to show requirements + pass + + # if attempt. def show_requirements(request): """This might just be a plain template without a view.""" @@ -29,3 +41,9 @@ def photo_id_upload(request): def final_verification(request): context = { "course_id" : "edX/Certs101/2013_Test" } return render_to_response("verify_student/final_verification.html", context) + +# + +def show_verification_page(request): + pass + From 1676fc31a5eb6c6de8c5efb93bda32e0b5b8955c Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 21 Aug 2013 16:21:18 -0400 Subject: [PATCH 042/185] Halfway state for course enrollment by mode --- common/djangoapps/course_modes/models.py | 5 ++ common/djangoapps/course_modes/views.py | 28 +++++++- common/djangoapps/student/views.py | 17 +++-- .../verify_student/tests/test_models.py | 2 +- .../verify_student/tests/test_ssencrypt.py | 7 +- .../verify_student/tests/test_views.py | 3 +- lms/djangoapps/verify_student/views.py | 65 ++++++++++++++++++- lms/templates/courseware/course_about.html | 7 +- lms/urls.py | 5 +- 9 files changed, 119 insertions(+), 20 deletions(-) diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 561c078b3b..721b587603 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -51,3 +51,8 @@ class CourseMode(models.Model): if not modes: modes = [cls.DEFAULT_MODE] return modes + + 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/views.py b/common/djangoapps/course_modes/views.py index 60f00ef0ef..2f7356a1a7 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -1 +1,27 @@ -# Create your views here. +from django.http import HttpResponse +from django.views.generic.base import View + +from mitxmako.shortcuts import render_to_response + +from course_modes.models import CourseMode + +class ChooseModeView(View): + + def get(self, request): + course_id = request.GET.get("course_id") + context = { + "course_id" : course_id, + "available_modes" : CourseMode.modes_for_course(course_id) + } + return render_to_response("course_modes/choose.html", context) + + def post(self, request): + course_id = request.GET.get("course_id") + mode_slug = request.POST.get("mode_slug") + 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 has_access(user, course, 'enroll'): + pass diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4d59b5cc66..b3f296080a 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -27,18 +27,19 @@ 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.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 @@ -328,7 +329,8 @@ def try_change_enrollment(request): except Exception, e: log.exception("Exception automatically enrolling after login: {0}".format(str(e))) - +@login_required +@require_POST def change_enrollment(request): """ Modify the enrollment status for the logged-in user. @@ -346,12 +348,7 @@ 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") @@ -371,6 +368,12 @@ 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.views.choose")) + org, course_num, run = course_id.split("/") statsd.increment("common.student.enrollment", tags=["org:{0}".format(org), diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index 838da26fba..249e1f2653 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -7,7 +7,7 @@ from student.tests.factories import UserFactory from verify_student.models import SoftwareSecurePhotoVerification, VerificationException -class TestPhotoVerification(object): +class TestPhotoVerification(TestCase): def test_state_transitions(self): """Make sure we can't make unexpected status transitions. diff --git a/lms/djangoapps/verify_student/tests/test_ssencrypt.py b/lms/djangoapps/verify_student/tests/test_ssencrypt.py index f2d063daf4..1e9978be7c 100644 --- a/lms/djangoapps/verify_student/tests/test_ssencrypt.py +++ b/lms/djangoapps/verify_student/tests/test_ssencrypt.py @@ -4,7 +4,7 @@ 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 + rsa_decrypt, rsa_encrypt, random_aes_key ) def test_aes(): @@ -76,8 +76,3 @@ l8N6+LEIVTMAytPk+/bImHvGHKZkCz5rEMSuYJWOmqKI92rUtI6fz5DUb3XSbrwT # 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) - - # Software Secure would decrypt our photo_id image by doing: - #rsa_encrypted_aes_key = base64.urlsafe_b64decode(encoded_photo_id_key) - #photo_id_aes_key = rsa_decrypt(rsa_encrypted_aes_key, priv_key_str) - diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 47b08f7b35..22426c811d 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -31,7 +31,8 @@ class StartView(TestCase): 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())) - \ No newline at end of file diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 3da2dd3bbe..5d113c68b1 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -6,6 +6,8 @@ from mitxmako.shortcuts import render_to_response from verify_student.models import SoftwareSecurePhotoVerification +from course_modes.models import CourseMode + # @login_required def start_or_resume_attempt(request, course_id): """ @@ -15,8 +17,8 @@ def start_or_resume_attempt(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_is_verified(user): - # pass + if SoftwareSecurePhotoVerification.user_is_verified(user): + pass attempt = SoftwareSecurePhotoVerification.active_for_user(request.user) if not attempt: @@ -47,3 +49,62 @@ def final_verification(request): 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', 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 SoftwareSecureVerification.has_submitted_recent_request(user): + # Capture payment info + # Create an order + # Create a VerifiedCertificate order item + return HttpResponse.Redirect(reverse('payment')) + + + # 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/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 39cf04e72c..ae3bb4e07c 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 { diff --git a/lms/urls.py b/lms/urls.py index 58c2cd3b55..1318fc6f74 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -65,7 +65,10 @@ urlpatterns = ('', # nopep8 ) if settings.MITX_FEATURES.get("MULTIPLE_ENROLLMENT_ROLES"): - urlpatterns += (url(r'^verify_student/', include('verify_student.urls')),) + urlpatterns += ( + url(r'^verify_student/', include('verify_student.urls')), + url(r'^course_modes/', include('course_modes.urls')), + ) js_info_dict = { From f5290e43977bff9d88499581f0d5fb27c7ff1575 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 16:40:42 -0400 Subject: [PATCH 043/185] Make each item purchase transaction atomic and add the ability to record the fulfillment time --- ...uto__add_field_orderitem_fulfilled_time.py | 114 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 18 ++- 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py diff --git a/lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py b/lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py new file mode 100644 index 0000000000..bbaf185184 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py @@ -0,0 +1,114 @@ +# -*- 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 field 'OrderItem.fulfilled_time' + db.add_column('shoppingcart_orderitem', 'fulfilled_time', + self.gf('django.db.models.fields.DateTimeField')(null=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'OrderItem.fulfilled_time' + db.delete_column('shoppingcart_orderitem', 'fulfilled_time') + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 415a9ebe50..490aac23a4 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -108,14 +108,14 @@ 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() # this should return all of the objects with the correct types of the # subclasses orderitems = OrderItem.objects.filter(order=self).select_subclasses() for item in orderitems: - item.status = 'purchased' - item.save() - item.purchased_callback() + item.purchase_item() class OrderItem(models.Model): @@ -136,6 +136,7 @@ class OrderItem(models.Model): unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) line_desc = models.CharField(default="Misc. Item", max_length=1024) currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes + fulfilled_time = models.DateTimeField(null=True) @property def line_cost(self): @@ -157,6 +158,17 @@ class OrderItem(models.Model): if order.currency != currency and order.orderitem_set.exists(): raise InvalidCartItem(_("Trying to add a different currency into the cart")) + @transaction.commit_on_success + def purchase_item(self): + """ + This is basically a wrapper around purchased_callback that handles + modifying the OrderItem itself + """ + self.purchased_callback() + self.status = 'purchased' + self.fulfilled_time = datetime.now(pytz.utc) + self.save() + def purchased_callback(self): """ This is called on each inventory item in the shopping cart when the From a5ee2add0506591802ddcd3658999367193a1706 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 13:30:52 -0700 Subject: [PATCH 044/185] started view tests --- lms/djangoapps/shoppingcart/tests/__init__.py | 0 .../{tests.py => tests/test_models.py} | 4 +-- .../shoppingcart/tests/test_views.py | 34 +++++++++++++++++++ lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 6 ---- 5 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/tests/__init__.py rename lms/djangoapps/shoppingcart/{tests.py => tests/test_models.py} (98%) create mode 100644 lms/djangoapps/shoppingcart/tests/test_views.py diff --git a/lms/djangoapps/shoppingcart/tests/__init__.py b/lms/djangoapps/shoppingcart/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests/test_models.py similarity index 98% rename from lms/djangoapps/shoppingcart/tests.py rename to lms/djangoapps/shoppingcart/tests/test_models.py index 39d0f0b301..f15edfed44 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -1,5 +1,5 @@ """ -Tests for the Shopping Cart +Tests for the Shopping Cart Models """ from factory import DjangoModelFactory @@ -12,7 +12,7 @@ from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartIt from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode -from .exceptions import PurchasedCallbackException +from ..exceptions import PurchasedCallbackException class OrderTest(TestCase): diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py new file mode 100644 index 0000000000..a05096ab92 --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -0,0 +1,34 @@ +""" +Tests for Shopping Cart views +""" +from django.test import TestCase +from django.test.utils import override_settings +from django.core.urlresolvers import reverse + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from shoppingcart.views import add_course_to_cart +from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration +from student.tests.factories import UserFactory +from student.models import CourseEnrollment +from course_modes.models import CourseMode +from ..exceptions import PurchasedCallbackException + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, DEBUG=True) +class ShoppingCartViewsTests(ModuleStoreTestCase): + def setUp(self): + self.user = UserFactory.create() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.cart = Order.get_cart_for_user(self.user) + + def test_add_course_to_cart_anon(self): + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 403) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 7893d29c20..8818a10c06 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -16,8 +16,7 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: if settings.DEBUG: urlpatterns += patterns( 'shoppingcart.views', - url(r'^(?P[^/]+/[^/]+/[^/]+)/$', 'test'), - url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart'), + url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', 'register_for_verified_cert'), ) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index be363f1422..39efab4771 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -14,12 +14,6 @@ from .processors import process_postpay_callback, render_purchase_form_html log = logging.getLogger("shoppingcart") -def test(request, course_id): - item1 = PaidCourseRegistration(course_id, 200) - item1.purchased_callback(request.user.id) - return HttpResponse('OK') - - def add_course_to_cart(request, course_id): if not request.user.is_authenticated(): return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart')) From 5b25940cde23a3aaecfec8afc9db66bb6b6b1092 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 18:56:03 -0700 Subject: [PATCH 045/185] shopping cart view tests. coverage full except for debug line --- lms/djangoapps/shoppingcart/models.py | 5 +- .../shoppingcart/tests/test_views.py | 193 ++++++++++++++++++ lms/djangoapps/shoppingcart/views.py | 2 +- lms/envs/test.py | 2 + 4 files changed, 200 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 490aac23a4..1ad71ff625 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -67,7 +67,10 @@ class Order(models.Model): @property def total_cost(self): - """ Return the total cost of the order """ + """ + Return the total cost of the cart. If the order has been purchased, returns total of + all purchased and not refunded items. + """ return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status)) def clear(self): diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index a05096ab92..96bcef6fcd 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -1,12 +1,16 @@ """ Tests for Shopping Cart views """ +from urlparse import urlparse + from django.test import TestCase from django.test.utils import override_settings from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.exceptions import ItemNotFoundError from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from shoppingcart.views import add_course_to_cart from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration @@ -14,11 +18,28 @@ from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode from ..exceptions import PurchasedCallbackException +from mitxmako.shortcuts import render_to_response +from shoppingcart.processors import render_purchase_form_html, process_postpay_callback +from mock import patch, Mock + +def mock_render_purchase_form_html(*args, **kwargs): + return render_purchase_form_html(*args, **kwargs) + +form_mock = Mock(side_effect=mock_render_purchase_form_html) + +def mock_render_to_response(*args, **kwargs): + return render_to_response(*args, **kwargs) + +render_mock = Mock(side_effect=mock_render_to_response) + +postpay_mock = Mock() @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, DEBUG=True) class ShoppingCartViewsTests(ModuleStoreTestCase): def setUp(self): self.user = UserFactory.create() + self.user.set_password('password') + self.user.save() self.course_id = "MITx/999/Robot_Super_Course" self.cost = 40 self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') @@ -29,6 +50,178 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.course_mode.save() self.cart = Order.get_cart_for_user(self.user) + def login_user(self): + self.client.login(username=self.user.username, password="password") + + def test_add_course_to_cart_anon(self): resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) self.assertEqual(resp.status_code, 403) + + def test_add_course_to_cart_already_in_cart(self): + 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.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.assertIn(_('You are already registered in course {0}.'.format(self.course_id)), resp.content) + + def test_add_nonexistent_course_to_cart(self): + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=['non/existent/course'])) + self.assertEqual(resp.status_code, 404) + self.assertIn(_("The course you requested does not exist."), resp.content) + + def test_add_course_to_cart_success(self): + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 200) + self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) + + def test_register_for_verified_cert(self): + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.register_for_verified_cert', args=[self.course_id])) + self.assertEqual(resp.status_code, 200) + self.assertIn(self.course_id, [ci.course_id for ci in + self.cart.orderitem_set.all().select_subclasses('certificateitem')]) + + @patch('shoppingcart.views.render_purchase_form_html', form_mock) + @patch('shoppingcart.views.render_to_response', render_mock) + 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') + resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) + self.assertEqual(resp.status_code, 200) + + ((purchase_form_arg_cart,), _) = form_mock.call_args + purchase_form_arg_cart_items = purchase_form_arg_cart.orderitem_set.all().select_subclasses() + self.assertIn(reg_item, purchase_form_arg_cart_items) + self.assertIn(cert_item, purchase_form_arg_cart_items) + self.assertEqual(len(purchase_form_arg_cart_items), 2) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/list.html') + self.assertEqual(len(context['shoppingcart_items']), 2) + self.assertEqual(context['amount'], 80) + self.assertIn("80.00", context['form_html']) + + 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') + self.assertEquals(self.cart.orderitem_set.count(), 2) + resp = self.client.post(reverse('shoppingcart.views.clear_cart', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 0) + + @patch('shoppingcart.views.log.exception') + 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') + self.assertEquals(self.cart.orderitem_set.count(), 2) + resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': reg_item.id}) + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 1) + self.assertNotIn(reg_item, self.cart.orderitem_set.all().select_subclasses()) + + self.cart.purchase() + resp2 = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': cert_item.id}) + self.assertEqual(resp2.status_code, 200) + exception_log.assert_called_with( + 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(cert_item.id)) + + resp3 = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': -1}) + self.assertEqual(resp3.status_code, 200) + exception_log.assert_called_with( + 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(-1)) + + @patch('shoppingcart.views.process_postpay_callback', postpay_mock) + def test_postpay_callback_success(self): + postpay_mock.return_value = {'success': True, 'order': self.cart} + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) + self.assertEqual(resp.status_code, 302) + self.assertEqual(urlparse(resp.__getitem__('location')).path, + reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + + @patch('shoppingcart.views.process_postpay_callback', postpay_mock) + @patch('shoppingcart.views.render_to_response', render_mock) + def test_postpay_callback_failure(self): + postpay_mock.return_value = {'success': False, 'order': self.cart, 'error_html':'ERROR_TEST!!!'} + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertIn('ERROR_TEST!!!', resp.content) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/error.html') + self.assertEqual(context['order'], self.cart) + self.assertEqual(context['error_html'], 'ERROR_TEST!!!') + + 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') + self.cart.purchase() + + user2 = UserFactory.create() + cart2 = Order.get_cart_for_user(user2) + PaidCourseRegistration.add_to_order(cart2, self.course_id) + cart2.purchase() + + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[cart2.id])) + self.assertEqual(resp.status_code, 404) + + resp2 = self.client.get(reverse('shoppingcart.views.show_receipt', args=[1000])) + self.assertEqual(resp2.status_code, 404) + + @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') + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + self.assertEqual(resp.status_code, 200) + self.assertIn('FirstNameTesting123', resp.content) + self.assertIn('StreetTesting123', resp.content) + self.assertIn('80.00', resp.content) + + ((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.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') + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + cert_item.status = "refunded" + cert_item.save() + self.assertEqual(self.cart.total_cost, 40) + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + self.assertEqual(resp.status_code, 200) + self.assertIn('40.00', resp.content) + + ((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.assertTrue(context['any_refunds']) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 39efab4771..a99568b133 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -26,7 +26,7 @@ def add_course_to_cart(request, course_id): PaidCourseRegistration.add_to_order(cart, course_id) except ItemNotFoundError: return HttpResponseNotFound(_('The course you requested does not exist.')) - if request.method == 'GET': + if request.method == 'GET': ### This is temporary for testing purposes and will go away before we pull return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) return HttpResponse(_("Course added to cart.")) diff --git a/lms/envs/test.py b/lms/envs/test.py index bf2df444f4..a9c51310f6 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -32,6 +32,8 @@ MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +MITX_FEATURES['ENABLE_SHOPPING_CART'] = True + # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. WIKI_ENABLED = True From b674aabbe717700ce957542ed406fc235f704e15 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 19:17:31 -0700 Subject: [PATCH 046/185] remove DEBUG flag from cart addition urls--causing test failure --- lms/djangoapps/shoppingcart/tests/test_views.py | 2 +- lms/djangoapps/shoppingcart/urls.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 96bcef6fcd..48c85900b0 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -34,7 +34,7 @@ render_mock = Mock(side_effect=mock_render_to_response) postpay_mock = Mock() -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, DEBUG=True) +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class ShoppingCartViewsTests(ModuleStoreTestCase): def setUp(self): self.user = UserFactory.create() diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 8818a10c06..533714b719 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -11,12 +11,8 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: url(r'^$', 'show_cart'), url(r'^clear/$', 'clear_cart'), url(r'^remove_item/$', 'remove_item'), - ) - -if settings.DEBUG: - urlpatterns += patterns( - 'shoppingcart.views', url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', 'register_for_verified_cert'), + ) From bd576e1e2305e8b8f41f9091af41e709129f3f40 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 22 Aug 2013 10:34:16 -0400 Subject: [PATCH 047/185] Pep8 fixes --- lms/djangoapps/shoppingcart/tests/test_models.py | 15 ++++++++++++++- lms/djangoapps/shoppingcart/tests/test_views.py | 9 +++++---- lms/djangoapps/shoppingcart/views.py | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index f15edfed44..75789964b1 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -3,8 +3,10 @@ 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.db import DatabaseError from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE @@ -12,7 +14,7 @@ from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartIt from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode -from ..exceptions import PurchasedCallbackException +from shoppingcart.exceptions import PurchasedCallbackException class OrderTest(TestCase): @@ -74,6 +76,17 @@ class OrderTest(TestCase): cart.purchase() self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + 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') + with patch('shoppingcart.models.CertificateItem.save', side_effect=DatabaseError): + with self.assertRaises(DatabaseError): + cart.purchase() + # verify that we rolled back the entire transaction + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + class OrderItemTest(TestCase): def setUp(self): diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 48c85900b0..b3b75870fc 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -22,6 +22,7 @@ from mitxmako.shortcuts import render_to_response from shoppingcart.processors import render_purchase_form_html, process_postpay_callback from mock import patch, Mock + def mock_render_purchase_form_html(*args, **kwargs): return render_purchase_form_html(*args, **kwargs) @@ -34,6 +35,7 @@ render_mock = Mock(side_effect=mock_render_to_response) postpay_mock = Mock() + @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class ShoppingCartViewsTests(ModuleStoreTestCase): def setUp(self): @@ -53,7 +55,6 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): def login_user(self): self.client.login(username=self.user.username, password="password") - def test_add_course_to_cart_anon(self): resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) self.assertEqual(resp.status_code, 403) @@ -141,7 +142,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(cert_item.id)) resp3 = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), - {'id': -1}) + {'id': -1}) self.assertEqual(resp3.status_code, 200) exception_log.assert_called_with( 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(-1)) @@ -158,7 +159,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): @patch('shoppingcart.views.process_postpay_callback', postpay_mock) @patch('shoppingcart.views.render_to_response', render_mock) def test_postpay_callback_failure(self): - postpay_mock.return_value = {'success': False, 'order': self.cart, 'error_html':'ERROR_TEST!!!'} + postpay_mock.return_value = {'success': False, 'order': self.cart, 'error_html': 'ERROR_TEST!!!'} self.login_user() resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) self.assertEqual(resp.status_code, 200) @@ -224,4 +225,4 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): 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.assertTrue(context['any_refunds']) \ No newline at end of file + self.assertTrue(context['any_refunds']) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index a99568b133..e10c3c94f9 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -26,7 +26,7 @@ def add_course_to_cart(request, course_id): PaidCourseRegistration.add_to_order(cart, course_id) except ItemNotFoundError: return HttpResponseNotFound(_('The course you requested does not exist.')) - if request.method == 'GET': ### This is temporary for testing purposes and will go away before we pull + if request.method == 'GET': # This is temporary for testing purposes and will go away before we pull return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) return HttpResponse(_("Course added to cart.")) From a0948668f434074e1759afb1f13a2608e5786ccb Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 22 Aug 2013 11:01:09 -0400 Subject: [PATCH 048/185] Remove unnecessary verified certificate view. --- lms/djangoapps/shoppingcart/tests/test_views.py | 6 ------ lms/djangoapps/shoppingcart/urls.py | 3 --- lms/djangoapps/shoppingcart/views.py | 10 ---------- 3 files changed, 19 deletions(-) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index b3b75870fc..25ee914ce6 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -85,12 +85,6 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) - def test_register_for_verified_cert(self): - self.login_user() - resp = self.client.post(reverse('shoppingcart.views.register_for_verified_cert', args=[self.course_id])) - self.assertEqual(resp.status_code, 200) - self.assertIn(self.course_id, [ci.course_id for ci in - self.cart.orderitem_set.all().select_subclasses('certificateitem')]) @patch('shoppingcart.views.render_purchase_form_html', form_mock) @patch('shoppingcart.views.render_to_response', render_mock) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 533714b719..800c6077aa 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -12,7 +12,4 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: url(r'^clear/$', 'clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), - url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', - 'register_for_verified_cert'), - ) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index e10c3c94f9..a2f88c9c94 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -31,16 +31,6 @@ def add_course_to_cart(request, course_id): return HttpResponse(_("Course added to cart.")) -@login_required -def register_for_verified_cert(request, course_id): - """ - Add a CertificateItem to the cart - """ - cart = Order.get_cart_for_user(request.user) - CertificateItem.add_to_order(cart, course_id, 30, 'verified') - return HttpResponse("Added") - - @login_required def show_cart(request): cart = Order.get_cart_for_user(request.user) From 0086a054d68b46a38d0d67dfd0414f29a5f09138 Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Thu, 22 Aug 2013 11:37:36 -0400 Subject: [PATCH 049/185] first pass at select a track for verification --- lms/static/sass/views/_verification.scss | 49 ++++++++++++ lms/templates/verify_student/face_upload.html | 17 +++-- .../verify_student/photo_id_upload.html | 76 ++++++++++++++++++- 3 files changed, 132 insertions(+), 10 deletions(-) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 7c30b44bd2..691652063c 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -13,9 +13,45 @@ body.register.verification { .title { @extend .t-title5; + margin-bottom: $baseline; + font-weight: bold; } } + .title { + @extend .t-title9; + margin-bottom: ($baseline/2); + font-weight: bold; + } + + .select { + @include clearfix(); + + + .block { + float: left; + margin: 0 $baseline $baseline 0; + background-color: #eee; + padding: $baseline; + width: 60%; + + .title { + @extend .t-title7; + } + + } + + .tips { + float: right; + width: 32%; + } + + } + + + + + .progress { .progress-step { @@ -70,6 +106,19 @@ body.register.verification { .control { display: inline-block; + + .action { + @extend .button-primary; + display: block; + background-color: $blue; + color: $white; + padding: ($baseline*.25) ($baseline*.5); + border: none; + + &:hover { + + } + } } } } diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html index c6cfaf3b35..42328eba0a 100644 --- a/lms/templates/verify_student/face_upload.html +++ b/lms/templates/verify_student/face_upload.html @@ -1,7 +1,7 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> <%inherit file="../main.html" /> -<%block name="bodyclass">register verification +<%block name="bodyclass">register verification photos <%block name="js_extra"> @@ -54,16 +54,17 @@ $(document).ready(function() { +
      @@ -121,13 +122,13 @@ $(document).ready(function() { @@ -268,7 +269,7 @@ $(document).ready(function() {
      - + close diff --git a/lms/templates/verify_student/photo_id_upload.html b/lms/templates/verify_student/photo_id_upload.html index b6724656f4..c5fa6d462e 100644 --- a/lms/templates/verify_student/photo_id_upload.html +++ b/lms/templates/verify_student/photo_id_upload.html @@ -1,11 +1,83 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> <%inherit file="../main.html" /> +<%block name="bodyclass">register verification select <%block name="content"> +
      +
      -

      Photo ID Upload!

      + -Final Verification +

      Select your track:

      +
      +
      +

      Audit

      +

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

      + +

      + Select Audit +

      +
      + +
      +
      +

      Certificate of Achievement

      +

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

      +
      +
      + Select your contribution for this course (in USD): +
      +
      +
        +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      +
      +
      + + Why do I have to pay? What if I don't meet all the requirements? + + + +

      + 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.

      + + +
      +
      From dad67cb90ab444015a5df8f5e3dd10b6c394b7d2 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 22 Aug 2013 10:43:10 -0700 Subject: [PATCH 050/185] change error HTTP response codes to 400 where appropriate --- lms/djangoapps/shoppingcart/tests/test_views.py | 4 ++-- lms/djangoapps/shoppingcart/views.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 25ee914ce6..eee685f84f 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -63,14 +63,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): diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index a2f88c9c94..0ed0c5407f 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,9 @@ 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: From 0dd581968328cec64e822f5dd5a7c3e472da147a Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 13:56:23 -0400 Subject: [PATCH 051/185] basic flow from enroll mode selection to verify screen --- cms/envs/common.py | 1 + common/djangoapps/course_modes/models.py | 4 ++ common/djangoapps/course_modes/urls.py | 9 ++++ common/djangoapps/course_modes/views.py | 44 +++++++++++++++--- common/djangoapps/student/views.py | 10 +++-- lms/djangoapps/verify_student/models.py | 45 +++++++++++++++---- .../verify_student/tests/test_views.py | 2 - lms/djangoapps/verify_student/urls.py | 6 +-- lms/djangoapps/verify_student/views.py | 42 ++++++++++------- lms/envs/common.py | 5 +++ 10 files changed, 130 insertions(+), 38 deletions(-) create mode 100644 common/djangoapps/course_modes/urls.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 29e99b2551..a06d5a36e1 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -363,6 +363,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 721b587603..6790630e3b 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -52,6 +52,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) } + 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/urls.py b/common/djangoapps/course_modes/urls.py new file mode 100644 index 0000000000..80ce7d4bda --- /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', 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 2f7356a1a7..0a563a932b 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -1,9 +1,18 @@ -from django.http import HttpResponse +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 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 class ChooseModeView(View): @@ -11,17 +20,42 @@ class ChooseModeView(View): course_id = request.GET.get("course_id") context = { "course_id" : course_id, - "available_modes" : CourseMode.modes_for_course(course_id) + "modes" : CourseMode.modes_for_course_dict(course_id) } return render_to_response("course_modes/choose.html", context) + def post(self, request): course_id = request.GET.get("course_id") - mode_slug = request.POST.get("mode_slug") 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 has_access(user, course, 'enroll'): - pass + if not has_access(user, course, 'enroll'): + return HttpResponseBadRequest(_("Enrollment is closed")) + + requested_mode = self.get_requested_mode(request.POST.get("mode")) + + 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') + + if requested_mode == "verified": + return redirect( + "{}?{}".format( + reverse('verify_student_verify'), + urlencode(dict(course_id=course_id)) + ) + ) + + def get_requested_mode(self, user_choice): + choices = { + "Select Audit" : "audit", + "Select Certificate" : "verified" + } + return choices.get(user_choice) \ No newline at end of file diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index b3f296080a..0808f56f3d 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -24,8 +24,7 @@ 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 @@ -372,7 +371,12 @@ def change_enrollment(request): # 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.views.choose")) + return HttpResponse( + "{}?{}".format( + reverse("course_modes_choose"), + urlencode(dict(course_id=course_id)) + ) + ) org, course_num, run = course_id.split("/") statsd.increment("common.student.enrollment", diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 816f5da8d4..7d9b9799c8 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -8,7 +8,7 @@ of a student over a period of time. Right now, the only models are the abstract `SoftwareSecurePhotoVerification`. The hope is to keep as much of the photo verification process as generic as possible. """ -from datetime import datetime +from datetime import datetime, timedelta from hashlib import md5 import base64 import functools @@ -17,6 +17,7 @@ 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 @@ -69,10 +70,10 @@ class PhotoVerification(StatusModel): 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 create and edit a - `PhotoVerification` object through the methods provided**. Do not - just construct one and start setting fields unless you really know what - you're doing. + 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: @@ -100,6 +101,9 @@ class PhotoVerification(StatusModel): 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') @@ -158,12 +162,37 @@ class PhotoVerification(StatusModel): ##### Methods listed in the order you'd typically call them @classmethod - def user_is_verified(cls, user): + 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.""" - raise NotImplementedError + 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__lte=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__lte=earliest_allowed_date + ).exists() @classmethod def active_for_user(cls, user): diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 22426c811d..0d37aa0efd 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -31,8 +31,6 @@ class StartView(TestCase): 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 index de08563970..43e258e912 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -35,9 +35,9 @@ urlpatterns = patterns( ), url( - r'^start_or_resume_attempt', - views.start_or_resume_attempt, - name="verify_student/start_or_resume_attempt" + r'^verify', + views.VerifyView.as_view(), + name="verify_student_verify" ) ) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 5d113c68b1..4876399ed6 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -4,28 +4,36 @@ """ from mitxmako.shortcuts import render_to_response -from verify_student.models import SoftwareSecurePhotoVerification +from django.conf import settings +from django.core.urlresolvers import reverse +from django.shortcuts import redirect +from django.views.generic.base import View from course_modes.models import CourseMode +from verify_student.models import SoftwareSecurePhotoVerification -# @login_required -def start_or_resume_attempt(request, course_id): - """ - If they've already started a PhotoVerificationAttempt, we move to wherever - they are in that process. If they've completed one, then we skip straight - to payment. - """ - # 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_is_verified(user): - pass +class VerifyView(View): - attempt = SoftwareSecurePhotoVerification.active_for_user(request.user) - if not attempt: - # Redirect to show requirements - pass + def get(self, request): + """ + """ + # 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): + progress_state = "payment" + 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" + + return render_to_response('verify_student/face_upload.html') + + + def post(request): + attempt = SoftwareSecurePhotoVerification(user=request.user) - # if attempt. def show_requirements(request): """This might just be a plain template without a view.""" diff --git a/lms/envs/common.py b/lms/envs/common.py index 22923de539..c178c1ca30 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -823,3 +823,8 @@ def enable_theme(theme_name): # avoid collisions with default edX static files 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? +} From 25d9c2f385f8c9083b5169a3415aa40c35d1598c Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 14:17:57 -0400 Subject: [PATCH 052/185] Merge design first pass at mode choice page --- common/templates/course_modes/choose.html | 98 ++++++++++++++++++++--- 1 file changed, 87 insertions(+), 11 deletions(-) diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index 6a12b02cfc..c9633a8790 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -2,22 +2,98 @@ <%! from django.core.urlresolvers import reverse %> <%inherit file="../main.html" /> -<%block name="content"> +<%block name="bodyclass">register verification select + +<%block name="content"> +
      +
      + + + +

      Select your track:

      + +
      - % if "audit" in modes: -
      - -
      -
      +
      +
      +

      Audit

      +

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

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

      Certificate of Achievement

      +

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

      +
      +
      + Select your contribution for this course (in USD): +
      +
      +
        +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      +
      +
      + + Why do I have to pay? What if I don't meet all the requirements? + + + +

      + What is an ID Verified 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 +

      +
      +
      % endif - -
      + + + + + +

      Have questions? Check out our FAQs.

      +

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

      + + +
      +
      From 5422fc94caa63ebe74e4b5628818d022206863fe Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 14:54:29 -0400 Subject: [PATCH 053/185] very stupid initial photo verification attempt save just so we can stuff into a cart --- common/djangoapps/course_modes/views.py | 3 ++- common/templates/course_modes/choose.html | 2 +- lms/djangoapps/verify_student/views.py | 13 ++++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 0a563a932b..a2ccc8583a 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -20,7 +20,8 @@ class ChooseModeView(View): course_id = request.GET.get("course_id") context = { "course_id" : course_id, - "modes" : CourseMode.modes_for_course_dict(course_id) + "modes" : CourseMode.modes_for_course_dict(course_id), + "course_name" : course_from_id(course_id).display_name } return render_to_response("course_modes/choose.html", context) diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index c9633a8790..6c9bf1a0a8 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -9,7 +9,7 @@

      Select your track:

      diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 4876399ed6..677d383bf8 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -10,6 +10,7 @@ from django.shortcuts import redirect from django.views.generic.base import View from course_modes.models import CourseMode +from student.views import course_from_id from verify_student.models import SoftwareSecurePhotoVerification class VerifyView(View): @@ -28,11 +29,21 @@ class VerifyView(View): # bookkeeping-wise just to start over. progress_state = "start" - return render_to_response('verify_student/face_upload.html') + context = { + "progress_state" : progress_state, + "user_full_name" : request.user.profile.name, + "course_name" : course_from_id(request.GET['course_id']).display_name + } + + return render_to_response('verify_student/photo_verification.html', context) def post(request): attempt = SoftwareSecurePhotoVerification(user=request.user) + attempt.status = "pending" + attempt.save() + + def show_requirements(request): From d46ed7ff5352a76b56d2c58d9f82bfd52211f833 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 14:54:38 -0400 Subject: [PATCH 054/185] very stupid initial photo verification attempt save just so we can stuff into a cart --- .../verify_student/photo_verification.html | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 lms/templates/verify_student/photo_verification.html diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html new file mode 100644 index 0000000000..fb098d3c65 --- /dev/null +++ b/lms/templates/verify_student/photo_verification.html @@ -0,0 +1,318 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="bodyclass">register verification photos + +<%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.

      +
      + + +
      + + + + + + + From 2b052c6fd3c252e42b4bcb1fb11217c36f629401 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 14:58:51 -0400 Subject: [PATCH 055/185] fix typo in config file --- lms/envs/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 6b96a4c2d7..627a813e05 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -156,10 +156,10 @@ MITX_FEATURES = { 'ENABLE_CHAT': False, # Allow users to enroll with methods other than just honor code certificates - 'MULTIPLE_ENROLLMENT_ROLES' : False + 'MULTIPLE_ENROLLMENT_ROLES' : False, # Toggle the availability of the shopping cart page - 'ENABLE_SHOPPING_CART': False + 'ENABLE_SHOPPING_CART': False, } # Used for A/B testing From 247c3ade700c2377ddc02b53dd6eb41626bc5fb8 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 15:00:56 -0400 Subject: [PATCH 056/185] Fix double requirement specification (django-model-utils) --- requirements/edx/base.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index c00b892c95..070f0a060d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -54,7 +54,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 From ff472e16f139307af1cfbc8951cbe52d4d96cfe7 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 15:52:59 -0400 Subject: [PATCH 057/185] Grabbing the basic order information we need to sent to CyberSource. --- .../shoppingcart/processors/CyberSource.py | 15 +++++++---- lms/djangoapps/verify_student/urls.py | 6 +++++ lms/djangoapps/verify_student/views.py | 27 +++++++++++++++---- .../verify_student/photo_verification.html | 5 ++-- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 5952668d8f..18e3a4a750 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -102,6 +102,15 @@ def render_purchase_form_html(cart): """ Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource """ + return render_to_string('shoppingcart/cybersource_form.html', { + 'action': purchase_endpoint, + 'params': get_signed_purchase_params(params), + }) + +def get_signed_purchase_params(cart): + return sign(get_purchase_params(cart)) + +def get_purchase_params(cart): purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '') total_cost = cart.total_cost @@ -112,12 +121,8 @@ 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 payment_accepted(params): diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 43e258e912..3f1a35685d 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -38,6 +38,12 @@ urlpatterns = patterns( r'^verify', views.VerifyView.as_view(), name="verify_student_verify" + ), + + url( + r'^create_order', + views.create_order, + name="verify_student_create_order" ) ) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 677d383bf8..7986b8c670 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -2,15 +2,21 @@ """ +import json + from mitxmako.shortcuts import render_to_response from django.conf import settings from django.core.urlresolvers import reverse +from django.http import HttpResponse from django.shortcuts import redirect from django.views.generic.base import View 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 from verify_student.models import SoftwareSecurePhotoVerification class VerifyView(View): @@ -29,21 +35,32 @@ class VerifyView(View): # bookkeeping-wise just to start over. progress_state = "start" + course_id = request.GET['course_id'] context = { "progress_state" : progress_state, "user_full_name" : request.user.profile.name, - "course_name" : course_from_id(request.GET['course_id']).display_name + "course_id" : course_id, + "course_name" : course_from_id(course_id).display_name } return render_to_response('verify_student/photo_verification.html', context) - def post(request): - attempt = SoftwareSecurePhotoVerification(user=request.user) - attempt.status = "pending" - attempt.save() +def create_order(request): + attempt = SoftwareSecurePhotoVerification(user=request.user) + attempt.status = "pending" + attempt.save() + course_id = request.POST['course_id'] + # I know, we should check this is valid. All kinds of stuff missing here + # enrollment = CourseEnrollment.create_enrollment(request.user, course_id) + cart = Order.get_cart_for_user(request.user) + CertificateItem.add_to_order(cart, course_id, 30, 'verified') + + params = get_signed_purchase_params(cart) + + return HttpResponse(json.dumps(params), content_type="text/json") def show_requirements(request): diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index fb098d3c65..a0f756717d 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -264,9 +264,10 @@
      • -
        + - + +

        Once you verify your details match the requirements, you can move on to step 4, payment on our secure server.

        From 478fc376f7cdff8ce0ce41264c622721b511a10e Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 17:50:50 -0400 Subject: [PATCH 058/185] Connect new button to mode selection --- lms/templates/courseware/mktg_course_about.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lms/templates/courseware/mktg_course_about.html b/lms/templates/courseware/mktg_course_about.html index 5903a453bc..784ca673b9 100644 --- a/lms/templates/courseware/mktg_course_about.html +++ b/lms/templates/courseware/mktg_course_about.html @@ -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 { From acd01306fe490c9525ba1b59f0afeb33d4e338ab Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 18:09:36 -0400 Subject: [PATCH 059/185] minor style change to keep videos from blowing out in verification --- lms/static/sass/views/_verification.scss | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 691652063c..c6e9ec4868 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -50,8 +50,6 @@ body.register.verification { - - .progress { .progress-step { @@ -163,4 +161,9 @@ body.register.verification { } + video { + width: 512px; + height: 384px; + } + } From 9b4387137397f0713faeb1bd76aead28bf5b90f2 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 21:14:18 -0400 Subject: [PATCH 060/185] Verification purchase goes through to CyberSource --- .../shoppingcart/processors/CyberSource.py | 7 ++- lms/djangoapps/verify_student/views.py | 11 +++- .../verify_student/photo_verification.html | 60 +++++++++++++------ 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 18e3a4a750..6340e4d6bb 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -103,7 +103,7 @@ def render_purchase_form_html(cart): Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource """ return render_to_string('shoppingcart/cybersource_form.html', { - 'action': purchase_endpoint, + 'action': get_purchase_endpoint(), 'params': get_signed_purchase_params(params), }) @@ -111,8 +111,6 @@ def get_signed_purchase_params(cart): return sign(get_purchase_params(cart)) def get_purchase_params(cart): - purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '') - total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() @@ -124,6 +122,9 @@ def get_purchase_params(cart): return params +def get_purchase_endpoint(): + return settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '') + def payment_accepted(params): """ diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 7986b8c670..f363c99a9c 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -3,6 +3,7 @@ """ import json +import logging from mitxmako.shortcuts import render_to_response @@ -16,9 +17,13 @@ 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 +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): def get(self, request): @@ -40,7 +45,8 @@ class VerifyView(View): "progress_state" : progress_state, "user_full_name" : request.user.profile.name, "course_id" : course_id, - "course_name" : course_from_id(course_id).display_name + "course_name" : course_from_id(course_id).display_name, + "purchase_endpoint" : get_purchase_endpoint(), } return render_to_response('verify_student/photo_verification.html', context) @@ -52,6 +58,7 @@ def create_order(request): attempt.save() course_id = request.POST['course_id'] + log.critical(course_id) # I know, we should check this is valid. All kinds of stuff missing here # enrollment = CourseEnrollment.create_enrollment(request.user, course_id) diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index a0f756717d..b49f132389 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -32,22 +32,50 @@ }, onFailSoHard); */ - $(document).ready(function() { - $( ".carousel-nav" ).addClass('sr'); - + function initVideoCapture() { window.URL = window.URL || window.webkitURL; navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; - var video = document.querySelector('video'); + $('video').each(function(i, video) { + if (navigator.getUserMedia) { + navigator.getUserMedia({video: true}, function(stream) { + video.src = window.URL.createObjectURL(stream); + }, onFailSoHard); + } else { + video.src = 'somevideo.webm'; // fallback. + } + }); + } - if (navigator.getUserMedia) { - navigator.getUserMedia({audio: true, video: true}, function(stream) { - video.src = window.URL.createObjectURL(stream); - }, onFailSoHard); - } else { - video.src = 'somevideo.webm'; // fallback. - } + $(document).ready(function() { + $(".carousel-nav").addClass('sr'); + + initVideoCapture(); + + $("#pay_button").click(function(){ + // $("#pay_form") + var xhr = $.post( + "create_order", + { + "course_id" : "${course_id|u}" + }, + function(data) { + for (prop in data) { + $('').attr({ + type: 'hidden', + name: prop, + value: data[prop] + }).appendTo('#pay_form'); + } + $("#pay_form").submit() + } + ) + .done(function(data) { + alert(data); + }) + .fail(function() { alert("error"); }); + }); }); @@ -84,8 +112,7 @@
        - -

        cam image

        +
        @@ -153,7 +180,6 @@
        -

        cam image

        @@ -264,11 +290,11 @@
        • -
          + - + - +

          Once you verify your details match the requirements, you can move on to step 4, payment on our secure server.

        • From 25874c29b058f5ad3f90597a44bf7f8d8d879bb5 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 22 Aug 2013 22:33:05 -0400 Subject: [PATCH 061/185] add face capture button --- lms/djangoapps/verify_student/views.py | 3 - .../verify_student/photo_verification.html | 116 ++++++++++-------- 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index f363c99a9c..89093b7c69 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -87,13 +87,10 @@ def final_verification(request): context = { "course_id" : "edX/Certs101/2013_Test" } return render_to_response("verify_student/final_verification.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. diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index b49f132389..8cc81bc5f8 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -8,74 +8,79 @@ + + + <%block name="content">
          -
          From 061b994eb70978661eb58868454be6be0b110e3f Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Thu, 22 Aug 2013 22:53:51 -0400 Subject: [PATCH 063/185] initial html bones of the verification requirements page --- lms/static/sass/views/_verification.scss | 42 +++++----- lms/templates/verify_student/face_upload.html | 2 - .../verify_student/show_requirements.html | 79 ++++++++++++++++++- 3 files changed, 99 insertions(+), 24 deletions(-) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 7deafa1deb..23395f4370 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -143,28 +143,9 @@ body.register.verification { position: absolute; bottom: ($baseline*1.5); right: ($baseline*1.5); - - a { - color: $very-light-text; - - &:hover { - text-decoration: none; - border: none; - } - } - - &.green { - box-shadow: 0 2px 1px rgba(2,100,2,1); - background-color: rgba(0,136,1,1); - - &:hover { - box-shadow: 0 2px 1px rgba(2,100,2,1); - background-color: #029D03; - } - - } } + } hr { @@ -183,7 +164,28 @@ body.register.verification { } + .m-btn-primary { + a { + color: $very-light-text; + + &:hover { + text-decoration: none; + border: none; + } + } + + &.green { + box-shadow: 0 2px 1px rgba(2,100,2,1); + background-color: rgba(0,136,1,1); + + &:hover { + box-shadow: 0 2px 1px rgba(2,100,2,1); + background-color: #029D03; + } + + } + } .progress { diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html index 3427b82d3b..9451e211bf 100644 --- a/lms/templates/verify_student/face_upload.html +++ b/lms/templates/verify_student/face_upload.html @@ -11,8 +11,6 @@ $(document).ready(function() { $( ".carousel-nav" ).addClass('sr'); - - }); diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html index 5fa00a0145..80d4658a72 100644 --- a/lms/templates/verify_student/show_requirements.html +++ b/lms/templates/verify_student/show_requirements.html @@ -1,12 +1,87 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> <%inherit file="../main.html" /> +<%block name="bodyclass">register verification select <%block name="content"> +
          +
          -

          Requirements Page!

          + -Upload Face +
          +

          Your Progress

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

          What You Will Need to Register

          + +

          There are a few things you will need to register as an ID verified student :

          + +
          +
          Identification
          +
          An identification document like a drivers license, passport, student ID, or whatever is acceptable.
          + +
          Webcam
          +
          A webcam that connects to your computer, a modern browser, and whatever these
          + +
          credit or debit card
          +
          A credit or debit card that is Mastercard or Visa or whatever the requirements are goes here.
          +
          +

          Missing something? You can always take the Audit track.

          + +
          + +

          Steps to get Verified

          + +

          Go to Step 1: Take my Photo

          + +

          Step 1: Take Your Photo

          +

          To verify your identity, we need a clear and well-lit photo of your face to match it with your ID.

          + +

          Step 2: Take a photo of your ID

          +

          To verify your identity, we need a clear and well-lit photo of your ID to match it with your face.

          + +

          Step 3: Verify your submissions

          +

          Review your photos to verify they are correct.

          + +

          Step 4: Submit payment

          +

          Pay for your course using a major credit of debit cards.

          +
            +
          • Cards accepted:
          • +
          • Visa
          • +
          • Master Card
          • +
          • Maestro
          • +
          • Amex
          • +
          • Discover
          • +
          • JCB (provided it has the Discover logo on the card)
          • +
          • Diners Club
          • +
          • ...need to list several more once firm
          • +
          + +

          You're Ready to Start Learning

          + +

          You are now verified. You can sign up for more courses, or get started on your course once it starts. While you will need to re-verify in the course prior to exams or expercises, you may also have to re-verify if we feel your photo we have on file may be out of date.

          + + +

          Go to Step 1: Take my Photo

          + +
          + +
          +

          More questions? Check out our FAQs.

          +

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

          +
          + +
          From d4e8034fcaab6576213f777282f8dcbcb3940f4b Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Thu, 22 Aug 2013 23:25:57 -0400 Subject: [PATCH 064/185] refinements on the verification html and sass --- lms/static/sass/views/_verification.scss | 88 +++++++++---------- lms/templates/verify_student/face_upload.html | 38 ++++---- .../verify_student/photo_id_upload.html | 2 +- .../verify_student/show_requirements.html | 4 +- 4 files changed, 68 insertions(+), 64 deletions(-) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 23395f4370..4e1651ca80 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -8,6 +8,16 @@ body.register.verification { font-family: 'Open Sans', sans-serif; } + + dt { + margin-bottom: ($baseline*.75); + } + + dd { + margin: 0; + } + + .content-wrapper { background: none repeat scroll 0 0 #F7F7F7; } @@ -79,49 +89,6 @@ body.register.verification { &.block-cert { border-top: 5px solid #008801; - - ul { - list-style-type: none; - margin: 0; - padding: 0; - - li { - display: inline-block; - background-color: $light-gray; - padding: ($baseline/2) ($baseline*.75); - margin-right: ($baseline/4); - vertical-align: middle; - - label { - font-style: normal; - font-family: 'Open Sans', sans-serif; - font-weight: 400; - vertical-align: middle; - } - - &.other1 { - margin-right: -($baseline/4); - padding-right: ($baseline/4); - } - &.other2 { - padding: ($baseline/4) ($baseline*.75) ($baseline/4) 0; - } - - input { - vertical-align: middle; - } - } - - } - - dt { - margin-bottom: ($baseline*.75); - } - - dd { - margin: 0; - } - } @@ -160,10 +127,43 @@ body.register.verification { font-size: 14px; } } - } + .pay-options { + list-style-type: none; + margin: 0; + padding: 0; + + li { + display: inline-block; + background-color: $light-gray; + padding: ($baseline/2) ($baseline*.75); + margin-right: ($baseline/4); + vertical-align: middle; + + label { + font-style: normal; + font-family: 'Open Sans', sans-serif; + font-weight: 400; + vertical-align: middle; + } + + &.other1 { + margin-right: -($baseline/4); + padding-right: ($baseline/4); + } + &.other2 { + padding: ($baseline/4) ($baseline*.75) ($baseline/4) 0; + } + + input { + vertical-align: middle; + } + } + + } + .m-btn-primary { a { diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html index 9451e211bf..95b8b30d34 100644 --- a/lms/templates/verify_student/face_upload.html +++ b/lms/templates/verify_student/face_upload.html @@ -37,7 +37,7 @@ $(document).ready(function() {
          -

          More questions? Check out our FAQs.

          -

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

          +

          More questions? Check out our FAQs.

          +

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

          From 8ee9a967669b8e2d3bce39afb9c073d8103173f7 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 23 Aug 2013 01:02:23 -0400 Subject: [PATCH 065/185] quick experiment to force student verification urls to be active --- lms/urls.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lms/urls.py b/lms/urls.py index 4cc2f7095a..7a352db9fc 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -64,11 +64,11 @@ urlpatterns = ('', # nopep8 ) -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')), - ) +# 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 = { From 2cdddef924eadacb8499fec80ae6b0a07f652da7 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 23 Aug 2013 01:27:08 -0400 Subject: [PATCH 066/185] Remove useless critical log, remove unnecessary course_id escaping. --- lms/djangoapps/verify_student/views.py | 1 - lms/templates/verify_student/photo_verification.html | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 89093b7c69..79ad5589b7 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -58,7 +58,6 @@ def create_order(request): attempt.save() course_id = request.POST['course_id'] - log.critical(course_id) # I know, we should check this is valid. All kinds of stuff missing here # enrollment = CourseEnrollment.create_enrollment(request.user, course_id) diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 8cc81bc5f8..b8fb88eed4 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -34,7 +34,7 @@ var xhr = $.post( "create_order", { - "course_id" : "${course_id|u}" + "course_id" : "${course_id}" }, function(data) { for (prop in data) { From cb168523139e0dd0b6321005b9cd0046cc5a0dca Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Fri, 23 Aug 2013 10:17:52 -0400 Subject: [PATCH 067/185] completed styling on select a track for verification --- lms/static/sass/views/_verification.scss | 73 +++++++++++++------ .../verify_student/photo_id_upload.html | 38 ++++++---- 2 files changed, 73 insertions(+), 38 deletions(-) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 4e1651ca80..0a85567d19 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -4,27 +4,54 @@ body.register.verification { font-family: 'Open Sans', sans-serif; - h1, h2, h3, h4, h5, h6, p { + h1, h2, h3, h4, h5, h6, p, input { font-family: 'Open Sans', sans-serif; } + input { + font-style: normal; + font-weight: 400; + } + + + label { + font-style: normal; + font-family: 'Open Sans', sans-serif; + font-weight: 400; + } + + dl.faq { + font-size: 12px; + + label { + font-size: 12px; + font-weight: bold; + } + } dt { - margin-bottom: ($baseline*.75); + margin: 0 0 .5em 0; + font-weight: bold; } dd { - margin: 0; + margin: 0 0 1em 0; } + dl dl { + margin: ($baseline/4) 0 0 ($baseline/2); + } + + .content-wrapper { background: none repeat scroll 0 0 #F7F7F7; + padding-bottom: 0; } .container { background-color: #fff; - padding: ($baseline*1.5); + padding: ($baseline*1.5) ($baseline*1.5) ($baseline*2) ($baseline*1.5); } .tip { @@ -89,6 +116,16 @@ body.register.verification { &.block-cert { border-top: 5px solid #008801; + + .ribbon { + background: transparent url('../images/vcert-ribbon-s.png') no-repeat 0 0; + position: absolute; + top: -($baseline*1.5); + right: $baseline; + display: block; + width: ($baseline*3); + height: ($baseline*4); + } } @@ -119,6 +156,11 @@ body.register.verification { margin: 1em 0 2em 0; } + .more { + margin-top: ($baseline/2); + border-top: 1px solid #ccc; + } + .tips { float: right; width: 32%; @@ -142,13 +184,6 @@ body.register.verification { margin-right: ($baseline/4); vertical-align: middle; - label { - font-style: normal; - font-family: 'Open Sans', sans-serif; - font-weight: 400; - vertical-align: middle; - } - &.other1 { margin-right: -($baseline/4); padding-right: ($baseline/4); @@ -157,6 +192,10 @@ body.register.verification { padding: ($baseline/4) ($baseline*.75) ($baseline/4) 0; } + label { + vertical-align: middle; + } + input { vertical-align: middle; } @@ -263,20 +302,8 @@ body.register.verification { width: 45%; float: left; padding-right: $baseline; - - dt { - font-weight: bold; - padding: 0 0 ($baseline/2) 0; - } - - dd { - margin: 0; - padding: 0 0 $baseline 0; - } } - - } .photo-tips { diff --git a/lms/templates/verify_student/photo_id_upload.html b/lms/templates/verify_student/photo_id_upload.html index 22f8c89ab2..eac9c1a362 100644 --- a/lms/templates/verify_student/photo_id_upload.html +++ b/lms/templates/verify_student/photo_id_upload.html @@ -41,7 +41,7 @@ $(document).ready(function() {
        @@ -51,6 +51,7 @@ $(document).ready(function() {

        Certificate of Achievement

        +

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

        @@ -70,10 +71,10 @@ $(document).ready(function() {
      • - +
      • - +
      @@ -84,23 +85,30 @@ $(document).ready(function() {

      -
      +
      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.
      +
      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. +
      -
      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.
      +
      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. +

      +
      @@ -114,7 +122,7 @@ $(document).ready(function() {

      - Register for Certificate + Select Certificate

      From cbb259eb1f4705e8b9145c592ca5c6a101232039 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 23 Aug 2013 11:07:06 -0400 Subject: [PATCH 068/185] Allow amounts specified to actually work for purchasing certificates --- common/djangoapps/course_modes/views.py | 8 ++++++++ common/templates/course_modes/choose.html | 19 +++++++------------ lms/djangoapps/verify_student/views.py | 7 ++++++- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index a2ccc8583a..13bbb2d4f8 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -47,6 +47,14 @@ class ChooseModeView(View): return redirect('dashboard') if requested_mode == "verified": + amount = request.POST.get("contribution") or \ + request.POST.get("contribution-other-amt") or \ + requested_mode.min_price + + donation_for_course = request.session.get("donation_for_course", {}) + donation_for_course[course_id] = float(amount) + request.session["donation_for_course"] = donation_for_course + return redirect( "{}?{}".format( reverse('verify_student_verify'), diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index 6c9bf1a0a8..111bdf8dfc 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -39,24 +39,19 @@

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

      - Select your contribution for this course (in USD): + Select your contribution for this course (in ${modes["verified"].currency.upper()|h}):
        + % for price in modes["verified"].suggested_prices.split(","):
      • - +
      • + % endfor
      • - -
      • -
      • - -
      • -
      • - -
      • -
      • - + + + $
      diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 79ad5589b7..18461bc180 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -58,11 +58,16 @@ def create_order(request): attempt.save() course_id = request.POST['course_id'] + donation_for_course = request.session.get("donation_for_course", {}) + + # FIXME: When this isn't available we do...? + amount = donation_for_course.get(course_id) # I know, we should check this is valid. All kinds of stuff missing here # enrollment = CourseEnrollment.create_enrollment(request.user, course_id) cart = Order.get_cart_for_user(request.user) - CertificateItem.add_to_order(cart, course_id, 30, 'verified') + cart.clear() + CertificateItem.add_to_order(cart, course_id, amount, 'verified') params = get_signed_purchase_params(cart) From 3c79e81c9d12148decd097bec6d8b9298ae101d2 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 23 Aug 2013 11:37:34 -0400 Subject: [PATCH 069/185] default to a min price if we loose track of how much they wanted to pay --- lms/djangoapps/verify_student/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 18461bc180..78c653bea7 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -61,7 +61,8 @@ def create_order(request): donation_for_course = request.session.get("donation_for_course", {}) # FIXME: When this isn't available we do...? - amount = donation_for_course.get(course_id) + verified_mode = CourseMode.modes_for_course_dict(course_id)["verified"] + amount = donation_for_course.get(course_id, verified_mode.min_price) # I know, we should check this is valid. All kinds of stuff missing here # enrollment = CourseEnrollment.create_enrollment(request.user, course_id) From 0fabba1553e9439643728ad064ac62527833dea3 Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Fri, 23 Aug 2013 13:08:05 -0400 Subject: [PATCH 070/185] added vcert images and some fake js functionality for photos --- lms/static/images/vcert-ribbon-s.png | Bin 0 -> 4843 bytes lms/static/images/vcert-steps.png | Bin 0 -> 81554 bytes lms/static/sass/views/_verification.scss | 61 +++++++++++++++- lms/templates/verify_student/face_upload.html | 69 +++++++++++++----- 4 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 lms/static/images/vcert-ribbon-s.png create mode 100644 lms/static/images/vcert-steps.png diff --git a/lms/static/images/vcert-ribbon-s.png b/lms/static/images/vcert-ribbon-s.png new file mode 100644 index 0000000000000000000000000000000000000000..adedb7cd311d89810ff95941f1719f69848208d3 GIT binary patch literal 4843 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 z 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 AcK`qY literal 0 HcmV?d00001 diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 0a85567d19..2f558c0eac 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -20,7 +20,7 @@ body.register.verification { font-weight: 400; } - dl.faq { + .faq { font-size: 12px; label { @@ -214,6 +214,17 @@ body.register.verification { } } + &.disabled, &[disabled], &.is-disabled { + background-color: #A0B6CD; + box-shadow: none; + pointer-events: none; + + &:hover { + background: $action-primary-disabled-bg !important; // needed for IE currently + } + + } + &.green { box-shadow: 0 2px 1px rgba(2,100,2,1); background-color: rgba(0,136,1,1); @@ -228,9 +239,11 @@ body.register.verification { .progress { - + /* background: transparent url('../images/vcert-steps.png') no-repeat 0 0; + height: 50px; + text-indent: -9999px; +*/ .progress-step { - border: 1px solid #eee; display: inline-block; padding: ($baseline/2) $baseline; } @@ -275,6 +288,8 @@ body.register.verification { background-color: #ddd; .controls-list { + @include clearfix(); + position: relative; margin: 0; padding: ($baseline*.25) ($baseline*.75); list-style-type: none; @@ -293,6 +308,36 @@ body.register.verification { &:hover { } + + } + + + &.is-hidden { + visibility: hidden; + } + + &.is-shown { + visibility: visible; + } + + + &.control-do { + position: relative; + left: 45%; + } + + &.control-redo { + position: absolute; + left: ($baseline/2); + } + + &.control-approve { + position: absolute; + right: ($baseline/2); + } + + &.approved a { + background-color: $green; } } } @@ -314,6 +359,16 @@ body.register.verification { .actions { width: 45%; float: right; + + ul { + padding: 0; + margin: 0; + list-style-type: none; + } + } + + .support { + margin-top: ($baseline*2); } .review-photo { diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html index 95b8b30d34..8a92fa438d 100644 --- a/lms/templates/verify_student/face_upload.html +++ b/lms/templates/verify_student/face_upload.html @@ -11,6 +11,39 @@ $(document).ready(function() { $( ".carousel-nav" ).addClass('sr'); + $('.block-photo .control-redo').addClass('is-hidden'); + $('.block-photo .control-approve').addClass('is-hidden'); + $('.block-photo .m-btn-primary').addClass('disabled'); + + + $( "#wrapper-facephoto .control-do" ).click(function(e) { + e.preventDefault(); + $(this).toggleClass('is-hidden'); + $('#wrapper-facephoto .control-redo').toggleClass('is-shown'); + $('#wrapper-facephoto .control-approve').toggleClass('is-shown'); + }); + + $( "#wrapper-facephoto .control-approve" ).click(function(e) { + e.preventDefault(); + $(this).addClass('approved'); + $('#wrapper-facephoto .m-btn-primary').removeClass('disabled'); + }); + + + $( "#wrapper-idphoto .control-do" ).click(function(e) { + e.preventDefault(); + $(this).toggleClass('is-hidden'); + $('#wrapper-idphoto .control-redo').toggleClass('is-shown'); + $('#wrapper-idphoto .control-approve').toggleClass('is-shown'); + }); + + $( "#wrapper-idphoto .control-approve" ).click(function(e) { + e.preventDefault(); + $(this).addClass('approved'); + $('#wrapper-idphoto .m-btn-primary').removeClass('disabled'); + }); + + }); @@ -51,12 +84,12 @@ $(document).ready(function() {

        -
      • - Take photo -
      • Retake
      • +
      • + Take photo +
      • Looks good
      • @@ -79,15 +112,13 @@ $(document).ready(function() {

        Common Questions

        -
        -
        Cras justo odio, dapibus ac facilisis in, egestas eget quam.
        -
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.
        -
        Vestibulum id ligula porta felis euismod semper.
        -
        Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
        -
        Aenean eu leo quam.
        -
        Pellentesque ornare sem lacinia quam venenatis vestibulum.
        -
        Maecenas faucibus mollis interdum.
        -
        +
        +
        Why do you need my photo?
        +
        We need your photo to confirm that you are you.
        + +
        What do you do with this picture?
        +
        We only use it to verify your identity. It is not displayed anywhere.
        +
        @@ -96,7 +127,7 @@ $(document).ready(function() {

        -

        Once you verify your photo looks good, you can move on to step 2.

        +

        Once you verify your photo looks good, you can move on to step 2.

      @@ -121,12 +152,12 @@ $(document).ready(function() {
        -
      • - Take photo -
      • Retake
      • +
      • + Take photo +
      • Looks good
      • @@ -151,7 +182,7 @@ $(document).ready(function() {

        Common Questions

        -
        +
        Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
        Aenean eu leo quam.
        Pellentesque ornare sem lacinia quam venenatis vestibulum.
        @@ -168,7 +199,7 @@ $(document).ready(function() {

        -

        Once you verify your ID photo looks good, you can move on to step 3.

        +

        Once you verify your ID photo looks good, you can move on to step 3.

      @@ -259,7 +290,7 @@ $(document).ready(function() {
      • Go to Step 4: Secure Payment

        -

        Once you verify your details match the requirements, you can move on to step 4, payment on our secure server.

        +

        Once you verify your details match the requirements, you can move on to step 4, payment on our secure server.

      From da829b1681c9aed7407d18570108829bdf82146a Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 23 Aug 2013 13:20:25 -0400 Subject: [PATCH 071/185] Make client-side capture work for photo IDs --- lms/static/sass/views/_verification.scss | 5 -- .../verify_student/photo_verification.html | 63 ++++++++++++------- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index c6e9ec4868..b99a3b1e7a 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -161,9 +161,4 @@ body.register.verification { } - video { - width: 512px; - height: 384px; - } - } diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index b8fb88eed4..bdf7a6d1ab 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -52,27 +52,45 @@ .fail(function() { alert("error"); }); } - function initFaceSnapshotHandler() { - var video = $('#face_video'); - var canvas = $('#face_canvas'); + function initSnapshotHandler(name) { + 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 ctx = canvas[0].getContext('2d'); var localMediaStream = null; function snapshot() { if (localMediaStream) { ctx.drawImage(video[0], 0, 0); - $('#face_image').src = canvas[0].toDataURL('image/webp'); + image[0].src = canvas[0].toDataURL('image/png'); } + video[0].pause(); + return false; + } + + function reset() { + $('#face_image')[0].src = ""; + video[0].play(); + return false; + } + + function approve() { + return false; } video.click(snapshot); - $("#face_capture_button").click(snapshot); + captureButton.click(snapshot); + resetButton.click(reset); + approveButton.click(approve); navigator.getUserMedia({video: true}, function(stream) { - video.src = window.URL.createObjectURL(stream); + video[0].src = window.URL.createObjectURL(stream); localMediaStream = stream; }, onVideoFail); - } $(document).ready(function() { @@ -80,8 +98,8 @@ $("#pay_button").click(submitToPaymentProcessing); initVideoCapture(); - initFaceSnapshotHandler(); - + initSnapshotHandler("face"); + initSnapshotHandler("photo_id"); }); @@ -117,22 +135,20 @@
      -
      -
      - - +
      +
      @@ -187,19 +203,20 @@
      - +
      +
      @@ -260,7 +277,7 @@
      - +

      The photo above needs to meet the following requirements:

      @@ -273,7 +290,7 @@
      - +

      The photo above needs to meet the following requirements:

      @@ -290,7 +307,7 @@ -
      -
      @@ -193,15 +189,11 @@ $(document).ready(function() {
      -
      -
      @@ -247,7 +239,7 @@ $(document).ready(function() {
      - +

      Check Your Contribution

      @@ -279,21 +271,14 @@ $(document).ready(function() {
      -
      diff --git a/lms/templates/verify_student/photo_id_upload.html b/lms/templates/verify_student/photo_id_upload.html index eac9c1a362..6d6cfb74e7 100644 --- a/lms/templates/verify_student/photo_id_upload.html +++ b/lms/templates/verify_student/photo_id_upload.html @@ -1,7 +1,7 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> <%inherit file="../main.html" /> -<%block name="bodyclass">register verification select +<%block name="bodyclass">register verification select-track <%block name="js_extra"> + <%block name="content">
      - - +

      Select your track:

      @@ -19,76 +32,109 @@ % if "audit" in modes:
      -

      Audit

      -

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

      +
      +

      Audit This Course

      +

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

      +
      - - +
      +

      + +

      +
      -
      + +

      or

      % endif % if "verified" in modes:

      Certificate of Achievement

      +

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

      + +
      - Select your contribution for this course (in ${modes["verified"].currency.upper()|h}): + Select your contribution for this course:
      -
        +
          % for price in modes["verified"].suggested_prices.split(","):
        • - +
        • % endfor -
        • - - - $ + +
        • + + +
        • +
        • + $
      - Why do I have to pay? What if I don't meet all the requirements? - - - -

      - What is an ID Verified Certificate? +

      + 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.
      + + % if "honor" in modes: +
      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. + + Enter $0 above and explain why you would like the fee waived below. Then click Select Certificate button to move on to the next step. +
      +
      +

      Tell us why you need help paying for this course in 180 characters or more.

      + + +
      +
      +
      + % endif + +
      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.
      + + % if "honor" in modes: +
      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. +

      +
      + % endif +
      +
      + +
      +

      + What is an ID Verified 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 + To register for a Verified Certificate of Achievement option, you will need a webcam, a credit or debit card, and an ID. View requirements

      -
      % endif - +
      - -

      Have questions? Check out our FAQs.

      -

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

      - - -
    +

    Have questions? Check out our FAQs.

    +

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

    +
    - From 874c6314f5036486287943191504ce8b156dff47 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 26 Aug 2013 14:19:42 -0400 Subject: [PATCH 075/185] Allow users who are not logged in to register. --- common/djangoapps/course_modes/views.py | 11 +++++++---- common/djangoapps/student/views.py | 19 ++++++++++++------- lms/templates/login.html | 4 +++- lms/templates/register.html | 7 ++++++- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 13bbb2d4f8..cd85146254 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -6,6 +6,8 @@ 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 @@ -16,12 +18,13 @@ from student.views import course_from_id class ChooseModeView(View): + @method_decorator(login_required) def get(self, request): course_id = request.GET.get("course_id") context = { - "course_id" : course_id, - "modes" : CourseMode.modes_for_course_dict(course_id), - "course_name" : course_from_id(course_id).display_name + "course_id": course_id, + "modes": CourseMode.modes_for_course_dict(course_id), + "course_name": course_from_id(course_id).display_name } return render_to_response("course_modes/choose.html", context) @@ -67,4 +70,4 @@ class ChooseModeView(View): "Select Audit" : "audit", "Select Certificate" : "verified" } - return choices.get(user_choice) \ No newline at end of file + return choices.get(user_choice) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 0808f56f3d..6d2b48d3db 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -325,10 +325,12 @@ 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))) -@login_required + @require_POST def change_enrollment(request): """ @@ -354,6 +356,9 @@ def change_enrollment(request): 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 @@ -458,10 +463,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 @@ -724,14 +729,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/lms/templates/login.html b/lms/templates/login.html index b737255a0d..5fad095024 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -50,7 +50,9 @@ next=u.split("next=")[1]; if (next && !isExternal(next)) { location.href=next; - } else { + } else if(json.redirect_url){ + location.href=json.redirect_url; + } else { location.href="${reverse('dashboard')}"; } } else { diff --git a/lms/templates/register.html b/lms/templates/register.html index 0e16bea345..a77bd465cb 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -53,7 +53,12 @@ $('#register-form').on('ajax:success', function(event, json, xhr) { if(json.success) { - location.href="${reverse('dashboard')}"; + if(json.redirect_url){ + location.href=json.redirect_url; + } + else { + location.href="${reverse('dashboard')}"; + } } else { toggleSubmitButton(true); $('.status.message.submission-error').addClass('is-shown').focus(); From dd50ec4e827b31a8d4e1eb2bbfaad1a2a05c4996 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 26 Aug 2013 14:31:36 -0400 Subject: [PATCH 076/185] Merge design and dev JS/UI for verify student --- common/djangoapps/course_modes/views.py | 4 +- lms/djangoapps/verify_student/urls.py | 19 +-- lms/djangoapps/verify_student/views.py | 2 +- lms/templates/courseware/course_about.html | 2 +- .../verify_student/photo_id_upload.html | 2 +- .../verify_student/photo_verification.html | 153 +++++++++++------- .../verify_student/show_requirements.html | 7 +- 7 files changed, 114 insertions(+), 75 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index cd85146254..e6add0e498 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -58,9 +58,11 @@ class ChooseModeView(View): donation_for_course[course_id] = float(amount) request.session["donation_for_course"] = donation_for_course + # TODO: Check here for minimum pricing + return redirect( "{}?{}".format( - reverse('verify_student_verify'), + reverse('verify_student_show_requirements'), urlencode(dict(course_id=course_id)) ) ) diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 3f1a35685d..f5fc5d2f7e 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -5,11 +5,6 @@ from verify_student import views urlpatterns = patterns( '', - url( - r'^show_requirements', - views.show_requirements, - name="verify_student/show_requirements" - ), url( r'^face_upload', views.face_upload, @@ -29,9 +24,9 @@ urlpatterns = patterns( # The above are what we did for the design mockups, but what we're really # looking at now is: url( - r'^show_verification_page', - views.show_verification_page, - name="verify_student/show_verification_page" + r'^show_requirements', + views.show_requirements, + name="verify_student_show_requirements" ), url( @@ -44,6 +39,12 @@ urlpatterns = patterns( r'^create_order', views.create_order, name="verify_student_create_order" - ) + ), + + url( + r'^show_verification_page', + 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 index 78c653bea7..631a54bb93 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -77,7 +77,7 @@ def create_order(request): def show_requirements(request): """This might just be a plain template without a view.""" - context = { "course_id" : "edX/Certs101/2013_Test" } + context = { "course_id" : request.GET.get("course_id") } return render_to_response("verify_student/show_requirements.html", context) def face_upload(request): diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 62dd2eb60d..17c18c2d90 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -101,7 +101,7 @@ %else: ${_("Register for {course.display_number_with_default}").format(course=course) | h} - Mock Verify Enrollment + Mock Verify Enrollment
    %endif diff --git a/lms/templates/verify_student/photo_id_upload.html b/lms/templates/verify_student/photo_id_upload.html index 3648b3ab6a..1c8ec47dd7 100644 --- a/lms/templates/verify_student/photo_id_upload.html +++ b/lms/templates/verify_student/photo_id_upload.html @@ -122,7 +122,7 @@ $(document).ready(function() {

    - Select Certificate + Select Certificate

    diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index bdf7a6d1ab..bb69d04f2a 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -93,13 +93,44 @@ }, onVideoFail); } + function initPhotoBlocks() { + // Photo wrapping + $('.block-photo .control-redo').addClass('is-hidden'); + $('.block-photo .control-approve').addClass('is-hidden'); + $('.block-photo .m-btn-primary').addClass('disabled'); + $( "#wrapper-facephoto .control-do" ).click(function(e) { + e.preventDefault(); + $(this).toggleClass('is-hidden'); + $('#wrapper-facephoto .control-redo').toggleClass('is-shown'); + $('#wrapper-facephoto .control-approve').toggleClass('is-shown'); + }); + $( "#wrapper-facephoto .control-approve" ).click(function(e) { + e.preventDefault(); + $(this).addClass('approved'); + $('#wrapper-facephoto .m-btn-primary').removeClass('disabled'); + }); + $( "#wrapper-idphoto .control-do" ).click(function(e) { + e.preventDefault(); + $(this).toggleClass('is-hidden'); + $('#wrapper-idphoto .control-redo').toggleClass('is-shown'); + $('#wrapper-idphoto .control-approve').toggleClass('is-shown'); + }); + $( "#wrapper-idphoto .control-approve" ).click(function(e) { + e.preventDefault(); + $(this).addClass('approved'); + $('#wrapper-idphoto .m-btn-primary').removeClass('disabled'); + }); + } + $(document).ready(function() { $(".carousel-nav").addClass('sr'); $("#pay_button").click(submitToPaymentProcessing); + initPhotoBlocks(); initVideoCapture(); initSnapshotHandler("face"); initSnapshotHandler("photo_id"); + }); @@ -141,12 +172,12 @@
      -
    • - Take photo -
    • Retake
    • +
    • + Take photo +
    • Looks good
    • @@ -169,32 +200,25 @@

      Common Questions

      -
      -
      Cras justo odio, dapibus ac facilisis in, egestas eget quam.
      -
      Lorem ipsum dolor sit amet, consectetur adipiscing elit.
      -
      Vestibulum id ligula porta felis euismod semper.
      -
      Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
      -
      Aenean eu leo quam.
      -
      Pellentesque ornare sem lacinia quam venenatis vestibulum.
      -
      Maecenas faucibus mollis interdum.
      -
      +
      +
      Why do you need my photo?
      +
      We need your photo to confirm that you are you.
      + +
      What do you do with this picture?
      +
      We only use it to verify your identity. It is not displayed anywhere.
      +
      -
      - +
      +

      + +

      +

      Once you verify your photo looks good, you can move on to step 2.

      - - - -
      +

      Take Your Photo

      Use your webcam to take a picture of your face so we can match it with the picture on your ID.

      @@ -209,18 +233,17 @@ -
      @@ -239,7 +262,7 @@

      Common Questions

      -
      +
      Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.
      Aenean eu leo quam.
      Pellentesque ornare sem lacinia quam venenatis vestibulum.
      @@ -250,27 +273,24 @@
      -
      - +
      +

      + +

      +

      Once you verify your ID photo looks good, you can move on to step 3.

      - -
      +

      Verify Your Submission

      Make sure we can verify your identity with the photos and information below.

      Check Your Name

      -

      Make sure your full name on your edX account, ${user_full_name}, matches your ID. We will also use this as the name on your certificate.

      +

      Make sure your full name on your edX account, [User Name], matches your ID. We will also use this as the name on your certificate.

      Edit my name

      @@ -302,37 +322,54 @@
      +
      + +
      +

      Check Your Contribution

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

      More questions? Check out our FAQs.

      -

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

      +

      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/show_requirements.html b/lms/templates/verify_student/show_requirements.html index b794317311..9e4780be0f 100644 --- a/lms/templates/verify_student/show_requirements.html +++ b/lms/templates/verify_student/show_requirements.html @@ -8,7 +8,7 @@
      @@ -52,7 +52,7 @@

      Steps to get Verified

      1 Take Your Photo

      @@ -82,9 +82,8 @@

      You are now verified. You can sign up for more courses, or get started on your course once it starts. While you will need to re-verify in the course prior to exams or expercises, you may also have to re-verify if we feel your photo we have on file may be out of date.

      -
      From 0387d766107b60c3f1eae57b350563723c8e09f8 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 26 Aug 2013 15:48:05 -0400 Subject: [PATCH 077/185] Fix disable/enable buttons in verification workflow. --- lms/templates/verify_student/face_upload.html | 2 +- .../verify_student/photo_verification.html | 87 ++++++++++++------- 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html index bd3845773f..6bfc18fd72 100644 --- a/lms/templates/verify_student/face_upload.html +++ b/lms/templates/verify_student/face_upload.html @@ -123,7 +123,7 @@ $(document).ready(function() {
      -

      +

      Once you verify your photo looks good, you can move on to step 2.

      diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index bb69d04f2a..39507fd1f8 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -16,21 +16,9 @@ window.URL = window.URL || window.webkitURL; navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; - - // // Make all the video pieces active - // $('video').each(function(i, video) { - // if (navigator.getUserMedia) { - // navigator.getUserMedia({video: true}, function(stream) { - // video.src = window.URL.createObjectURL(stream); - // }, onVideoFail); - // } else { - // video.src = 'somevideo.webm'; // fallback. - // } - // }); } var submitToPaymentProcessing = function() { - // $("#pay_form") var xhr = $.post( "create_order", { @@ -59,6 +47,7 @@ var captureButton = $("#" + name + "_capture_button"); var resetButton = $("#" + name + "_reset_button"); var approveButton = $("#" + name + "_approve_button"); + var nextButton = $("#" + name + "_next_button"); var ctx = canvas[0].getContext('2d'); var localMediaStream = null; @@ -69,19 +58,50 @@ image[0].src = canvas[0].toDataURL('image/png'); } video[0].pause(); + + captureButton.hide(); + resetButton.show(); + approveButton.show(); return false; } function reset() { $('#face_image')[0].src = ""; video[0].play(); + + approveButton.removeClass('approved'); + nextButton.addClass('disabled'); + + captureButton.show(); +// captureButton.removeClass('is-hidden'); + resetButton.hide(); +// resetButton.addClass('is-hidden'); + approveButton.hide(); +// approveButton.addClass('is-hidden'); + return false; } function approve() { + approveButton.addClass('approved'); + nextButton.removeClass('disabled'); + return false; } + // Initialize state for this picture taker + captureButton.show(); + //captureButton.removeClass('is-hidden'); + + resetButton.hide(); + //resetButton.addClass('is-hidden'); + + approveButton.hide(); + //approveButton.addClass('is-hidden'); + + nextButton.addClass('disabled'); + + // Connect event handlers... video.click(snapshot); captureButton.click(snapshot); resetButton.click(reset); @@ -95,7 +115,7 @@ function initPhotoBlocks() { // Photo wrapping - $('.block-photo .control-redo').addClass('is-hidden'); +/* $('.block-photo .control-redo').addClass('is-hidden'); $('.block-photo .control-approve').addClass('is-hidden'); $('.block-photo .m-btn-primary').addClass('disabled'); $( "#wrapper-facephoto .control-do" ).click(function(e) { @@ -119,12 +139,19 @@ e.preventDefault(); $(this).addClass('approved'); $('#wrapper-idphoto .m-btn-primary').removeClass('disabled'); - }); + }); */ } $(document).ready(function() { $(".carousel-nav").addClass('sr'); $("#pay_button").click(submitToPaymentProcessing); + $("#confirm_pics_good").click(function() { + if (this.checked) { + $("#pay_button").removeClass('disabled'); + } + }); + + $("#pay_button").addClass('disabled'); initPhotoBlocks(); initVideoCapture(); @@ -172,14 +199,14 @@ @@ -210,7 +237,7 @@
      -

      +

      Once you verify your photo looks good, you can move on to step 2.

      @@ -233,14 +260,14 @@ @@ -274,7 +301,7 @@
      -

      +

      Once you verify your ID photo looks good, you can move on to step 3.

      @@ -357,9 +384,9 @@

      Photos don't meet the requirements? Retake the photos.

      - + -

      Go to Step 4: Secure Payment

      +

      Go to Step 4: Secure Payment

      Once you verify your details match the requirements, you can move on to step 4, payment on our secure server.

      From d8a693fb16e9b160539fb28ca06a7efac71e775c Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 26 Aug 2013 16:01:08 -0400 Subject: [PATCH 078/185] Fix to make the submission from verified to payment go through again. --- .../verify_student/photo_verification.html | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 39507fd1f8..8346b05917 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -147,11 +147,11 @@ $("#pay_button").click(submitToPaymentProcessing); $("#confirm_pics_good").click(function() { if (this.checked) { - $("#pay_button").removeClass('disabled'); + $("#pay_button_frame").removeClass('disabled'); } }); - $("#pay_button").addClass('disabled'); + $("#pay_button_frame").addClass('disabled'); initPhotoBlocks(); initVideoCapture(); @@ -384,11 +384,19 @@

      Photos don't meet the requirements? Retake the photos.

      - +
      + + -

      Go to Step 4: Secure Payment

      -

      Once you verify your details match the requirements, you can move on to step 4, payment on our secure server.

      + + +

      + +

      +
      +

      Once you verify your details match the requirements, you can move on to step 4, payment on our secure server.

      +
      From 0712464f508ec4d6b86fc7b9922f884b2d765c39 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 26 Aug 2013 16:02:56 -0400 Subject: [PATCH 079/185] Remove mock verify enrollment link --- lms/templates/courseware/course_about.html | 3 --- 1 file changed, 3 deletions(-) diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 17c18c2d90..6abcd2998a 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -100,9 +100,6 @@ %else: ${_("Register for {course.display_number_with_default}").format(course=course) | h} - - Mock Verify Enrollment -
      %endif
      From e5cede572aaadc95a5ec893d3ce6c14cbd8310b1 Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Mon, 26 Aug 2013 18:36:56 -0400 Subject: [PATCH 080/185] fixing up some styling on vcerts --- common/templates/course_modes/choose.html | 14 +- lms/static/sass/base/_mixins.scss | 4 +- lms/static/sass/views/_verification.scss | 171 ++++++++++++++++-- .../verify_student/photo_verification.html | 18 +- .../verify_student/show_requirements.html | 109 +++++------ 5 files changed, 214 insertions(+), 102 deletions(-) diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index 679a32ae9c..ad46dab2c5 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -54,7 +54,6 @@

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

      -
      Select your contribution for this course: @@ -78,7 +77,7 @@
      -

      +

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

      @@ -87,6 +86,10 @@
      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.
      +
      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.
      + + % if "honor" in modes:
      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. @@ -102,13 +105,10 @@
      % endif -
      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.
      - % if "honor" in modes:
      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. -

      +

      % endif @@ -125,7 +125,7 @@

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

      % endif diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index 8ee4559e36..3dd7cb42a2 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -4,13 +4,13 @@ // 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 diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index fdb2fe36bf..01c37f5892 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -16,17 +16,19 @@ body.register.verification { input { font-style: normal; font-weight: 400; + margin-right: ($baseline/5); } - label { font-style: normal; font-family: 'Open Sans', sans-serif; font-weight: 400; } + + .faq { - font-size: 12px; + @extend .t-copy-sub2; label { font-size: 12px; @@ -60,17 +62,18 @@ body.register.verification { } .tip { + @extend .t-copy-sub2; @include transition(color 0.15s ease-in-out 0s); display: block; margin-top: ($baseline/4); color: $lighter-base-font-color; - font-size: em(13); } .page-header { .title { - @include font-size(18); + @include font-size(28); + @include line-height(28); margin-bottom: $baseline; border-bottom: 1px solid #ccc; padding-bottom: 20px; @@ -81,9 +84,9 @@ body.register.verification { } .title { - @extend .t-title9; + @extend .t-title5; margin-bottom: ($baseline/2); - font-weight: bold; + font-weight: 300; } @@ -124,10 +127,16 @@ body.register.verification { margin-bottom: 0; padding: 0; - a { + a, input { + background: none; + border: none; + box-shadow: none; color: $very-light-text; display: block; padding: ($baseline*.75) $baseline; + text-transform: none; + text-shadow: none; + letter-spacing: 0; &:hover { text-decoration: none; @@ -159,24 +168,39 @@ body.register.verification { } - .progress, - .steps { + .progress { @include clearfix; + position: relative; margin-bottom: $baseline; + border-bottom: 1px solid #ccc; + padding-bottom: $baseline; ol { margin: 0; padding: 0; } + .progress-step { + @extend .t-copy-sub1; + position: relative; + z-index: 200; display: inline-block; width: 15%; - padding: ($baseline/2) $baseline; + padding: ($baseline/2) 0; text-align: center; - font-size: 12px; color: #ccc; &.current { + color: #000; + + .number { + border: 4px solid #000; + color: #000; + } + } + + + &.done { color: #008801; .number { @@ -186,6 +210,8 @@ body.register.verification { } } + + .number { height: 2em; width: 2em; @@ -193,11 +219,38 @@ body.register.verification { margin: 0 auto ($baseline/2); border: 4px solid #ddd; border-radius: 20px; + background-color: #fff; line-height: 2em; text-align: center; color: #ccc; } + .mini { + height: .5em; + width: .5em; + margin-bottom: 1.5em; + } + + .progress-line, + .progress-line-done { + position: absolute; + top: 26%; + left: 8%; + height: 2px; + width: 100%; + display: inline-block; + background-color: #ddd; + } + + .progress-line-done { + width: 20%; + background-color: #008801; + } + + } + + .support { + margin-top: ($baseline*2); } @@ -219,13 +272,13 @@ body.register.verification { border-top: 2px solid #ddd; p { + @extend .t-copy-base; position: relative; top: -$baseline; width: 40px; margin: 0 auto; padding: 0 $baseline; background-color: #fff; - font-size: 24px; color: #aaa; text-align: center; } @@ -267,7 +320,8 @@ body.register.verification { } .title { - @extend .t-title7; + @extend .t-title4; + font-weight: bold; } .m-btn-primary { @@ -293,7 +347,7 @@ body.register.verification { width: 32%; p { - font-size: 14px; + @extend .t-copy-sub1; } } } @@ -301,16 +355,89 @@ body.register.verification { // requirements page &.requirements { - .req { - width: 30%; + + .section-head .title { + @extend .t-title4; display: inline-block; - margin-right: $baseline; + margin: ($baseline/4) 0; + } + + .reqs { + margin: $baseline; + } + + .req { + width: 27%; + display: inline-block; + margin-right: 1%; + border: 1px solid #ddd; + padding: $baseline 2%; text-align: center; vertical-align: top; + + // for placement only + .placeholder-art { + height: 150px; + width: 150px; + margin: $baseline auto; + background-color: #eee; + + i { + font-size: 24px; + } + } + } .next-step { float: right; + + .tip { + margin-top: $baseline; + } + + } + + hr { + margin: ($baseline*2); + } + + .steps-section { + + .section-head { + margin-bottom: ($baseline); + } + + .step { + width: 60%; + margin-left: 3%; + padding: ($baseline) ($baseline*1.5); + + .step-title { + @extend .t-title5; + font-weight: bold; + } + + .number { + @extend .t-title6; + height: 2em; + width: 2em; + display: inline-block; + margin: 0 ($baseline/2) 0 0; + border: 3px solid #000; + border-radius: 30px; + font-weight: bold; + line-height: 2em; + text-align: center; + color: #000; + } + + .copy { + @extend .t-copy-base; + margin-left: 65px; + } + } + } } @@ -369,9 +496,13 @@ body.register.verification { display: block; background-color: $blue; color: $white; - padding: ($baseline*.25) ($baseline*.5); border: none; + i { + padding: ($baseline*.25) ($baseline*.5); + display: block; + } + &:hover { } @@ -439,6 +570,10 @@ body.register.verification { .next-step { width: 45%; float: right; + + .tip { + margin-top: $baseline; + } } .support { diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 8346b05917..b78d539c67 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -171,19 +171,23 @@

      You are registering for ${course_name} (ID Verified)

      +
      -

      Your Progress

      +
      +

      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. +
      11. Intro
      12. +
      13. Current Step: 1 Take Photo
      14. +
      15. 2 Take ID Photo
      16. +
      17. 3 Confirm Submission
      18. +
      19. 4 Make Payment
      20. +
      21. Confirmation
      -
    diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index fc7244c511..b2f41953fd 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -159,263 +159,389 @@ initSnapshotHandler("photo_id"); }); - - <%block name="content">
    - +
    -
    -
    -

    Your Progress

    + - -
      -
    1. Intro
    2. -
    3. Current Step: 1 Take Photo
    4. -
    5. 2 Take ID Photo
    6. -
    7. 3 Confirm Submission
    8. -
    9. 4 Make Payment
    10. -
    11. Confirmation
    12. -
    -
    +
    +
    +

    Your Progress

    -
    -
    -

    Take Your Photo

    -

    Use your webcam to take a picture of your face so we can match it with the picture on your ID.

    +
    +
    -
    +
    -
    -

    - -

    -

    Once you verify your ID photo looks good, you can move on to step 3.

    -
    -
    +
    +
      +
    1. + +
    2. +
    + +
    + -
    -

    Verify Your Submission

    -

    Make sure we can verify your identity with the photos and information below.

    +
    +
    +

    Show Us Your ID

    +
    +

    Use your webcam to take a picture of your face so we can match it with the picture on your ID.

    +
    -
    -

    Check Your Name

    -

    Make sure your full name on your edX account, [User Name], matches your ID. We will also use this as the name on your certificate.

    -

    Edit my name

    -
    +
    +
    +
    +
    + +
    -
    -
    -
    - -
    +
    +
      +
    • + + Retake + +
    • -

      The photo above needs to meet the following requirements:

      -
        -
      • Be well lit
      • -
      • Show your whole face
      • -
      • Match your ID
      • -
      -
    +
  8. + + Take photo + +
  9. -
    -
    - -
    +
  10. + + Looks good + +
  11. + +
    +
    -

    The photo above needs to meet the following requirements:

    -
      -
    • Be readable (not too far away, no glare)
    • -
    • Show your name
    • -
    • Match the photo of your face and your name above
    • -
    -
    +
    +
    +

    Tips on taking a successful photo

    +
    +
      +
    • Make sure your ID is well-lit
    • +
    • Check that there isn't any glare
    • +
    • Ensure that you can see your photo and read your name
    • +
    • Try to keep your fingers at the edge to avoid covering important information
    • +
    +
    +
    -
    -

    Photos don't meet the requirements? Retake the photos.

    +
    +

    Common Questions

    -
    +
    +
    +
    Why do you need a photo of my ID?
    +
    We need to match your ID with your photo and name to confirm that you are you.
    -
    -

    Check Your Contribution

    +
    What do you do with this picture?
    +
    We encrypt it and send it to our secure authorization service for review. We use the highest levels of security and do not save the photo or information anywhere once the match has been completed.
    +
    +
    +
    +
    +
    -
    -
    - Select your contribution for this course: -
    -
    -
      -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    -
    -
    -
    + +
    +
    -
    +
    +
    +

    Verify Your Submission

    +
    +

    Make sure we can verify your identity with the photos and information below.

    +
    -
    - - +
    +
      +
    1. +

      Check Your Name

      -
      +
      +

      Make sure your full name on your edX account, [User Name], matches your ID. We will also use this as the name on your certificate.

      +
      - -

      - -

      -
    2. -

      Once you verify your details match the requirements,
      you can move on to step 4, payment on our secure server.

      -
    -
    + + + +
  12. +

    Review the Photos You've Taken

    + +
    +

    Make sure your full name on your edX account, [User Name], matches your ID. We will also use this as the name on your certificate.

    +
    + +
      +
    1. +
      + +
      + +
      +
      The photo above needs to meet the following requirements:
      +
        +
      • Be well lit
      • +
      • Show your whole face
      • +
      • Match your ID
      • +
      +
      +
    2. + +
    3. +
      + +
      + +
      +
      The photo above needs to meet the following requirements:
      +
        +
      • Be readable (not too far away, no glare)
      • +
      • Show your name
      • +
      • Match the photo of your face and your name above
      • +
      +
      +
    4. +
    +
  13. + +
  14. +

    Check Your Contribution Level

    + +
    +

    Please confirm your contribution for this course:

    +
    + +
      +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    • + +
      + $ + + USD +
      +
    • +
    +
  15. + +
    + + +
    + + + + + +
    + +
    - - - -
    -

    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/show_requirements.html b/lms/templates/verify_student/show_requirements.html index ce8715764e..aeba9edad7 100644 --- a/lms/templates/verify_student/show_requirements.html +++ b/lms/templates/verify_student/show_requirements.html @@ -7,65 +7,140 @@
    - -

    There are a few things you will need to register as an ID verified student:

    -
    -
    -

    Identification

    -
    -

    A photo identification document a drivers license, passport, student ID, or other ID with your name and picture on it

    +
    +

    There are a few things you will need to register as an ID verified student:

    -
    -

    Webcam

    - -

    A webcam and a modern browser Firefox, Chrome, or Opera

    -
    +
      +
    • +

      Identification

      +
      + +
      -
      -

      Credit or Debit Card

      - -

      A major credit or debit card Visa, Master Card, Maestro, Amex, Discover, JCB with Discover logo, Diners Club

      -
      -
    +
    +

    + A photo identification document + a drivers license, passport, student ID, or other ID with your name and picture on it +

    +
    + - -
    +
  16. +

    Webcam

    +
    + +
    +
    +

    + A webcam and a modern browser + Firefox, Chrome, or Opera +

    +
    +
  17. -
    -

    Missing something? You can always Audit the course.

    -

    More questions? Check out our FAQs.

    -
    +
  18. +

    Credit or Debit Card

    +
    + +
    +
    +

    + A major credit or debit card + Visa, Master Card, Maestro, Amex, Discover, JCB with Discover logo, Diners Club +

    +
    +
  19. + + + + +
    + +
    + +
    - From 7af4accc50705b5fb5e7e09fab9befe97c6dc520 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 28 Aug 2013 15:06:58 -0400 Subject: [PATCH 093/185] Preserve purchase amount choice and redisplay on final verification page. --- common/djangoapps/course_modes/views.py | 5 +++-- common/templates/course_modes/_contribution.html | 6 +++--- common/templates/course_modes/choose.html | 2 +- lms/djangoapps/verify_student/views.py | 8 ++++++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 5c1be54f2c..d98ac6fa0b 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -26,6 +26,7 @@ class ChooseModeView(View): "course_id": course_id, "modes": modes, "course_name": course_from_id(course_id).display_name, + "chosen_price" : None, } if "verified" in modes: context["suggested_prices"] = modes["verified"].suggested_prices.split(",") @@ -57,10 +58,10 @@ class ChooseModeView(View): if requested_mode == "verified": amount = request.POST.get("contribution") or \ request.POST.get("contribution-other-amt") or \ - requested_mode.min_price + requested_mode.min_price.format("{:g}") donation_for_course = request.session.get("donation_for_course", {}) - donation_for_course[course_id] = float(amount) + donation_for_course[course_id] = amount request.session["donation_for_course"] = donation_for_course # TODO: Check here for minimum pricing diff --git a/common/templates/course_modes/_contribution.html b/common/templates/course_modes/_contribution.html index 33ce39a7c7..c86311d8ff 100644 --- a/common/templates/course_modes/_contribution.html +++ b/common/templates/course_modes/_contribution.html @@ -6,16 +6,16 @@
      % for price in suggested_prices:
    • - +
    • % endfor
    • - +
    • - $ + $
    diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index 9ed96e0826..7f2aa01555 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -54,7 +54,7 @@

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

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

    Why do I have to pay? What if I don't meet all the requirements? diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index ed6fd64d32..4c76251d84 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -42,15 +42,19 @@ class VerifyView(View): course_id = request.GET['course_id'] 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 = { "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" : [int(price) for price in verify_mode.suggested_prices.split(",")], + "suggested_prices" : verify_mode.suggested_prices.split(","), "currency" : verify_mode.currency.upper(), - "chosen_price" : request.session.get("donation_for_course", verify_mode.min_price) + "chosen_price" : chosen_price, } return render_to_response('verify_student/photo_verification.html', context) From 4471079f71c733a78a90ed900c9c4e15964cf3c7 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 28 Aug 2013 16:43:09 -0400 Subject: [PATCH 094/185] Clean up validation of price selection. Add error messaging. --- common/djangoapps/course_modes/views.py | 22 +++++++----- common/templates/course_modes/choose.html | 5 +++ .../shoppingcart/processors/CyberSource.py | 2 +- lms/djangoapps/verify_student/views.py | 34 +++++++++++-------- .../verify_student/photo_verification.html | 2 +- 5 files changed, 40 insertions(+), 25 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index d98ac6fa0b..b3c3e0bbfb 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -19,14 +19,15 @@ from student.views import course_from_id class ChooseModeView(View): @method_decorator(login_required) - def get(self, request): + def get(self, request, error=None): course_id = request.GET.get("course_id") 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, + "chosen_price": None, + "error": error, } if "verified" in modes: context["suggested_prices"] = modes["verified"].suggested_prices.split(",") @@ -43,7 +44,8 @@ class ChooseModeView(View): # 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'): - return HttpResponseBadRequest(_("Enrollment is closed")) + error_msg = _("Enrollment is closed") + return self.get(request, error=error_msg) requested_mode = self.get_requested_mode(request.POST.get("mode")) @@ -55,16 +57,20 @@ class ChooseModeView(View): 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 \ - requested_mode.min_price.format("{:g}") + request.POST.get("contribution-other-amt") or 0 donation_for_course = request.session.get("donation_for_course", {}) donation_for_course[course_id] = amount request.session["donation_for_course"] = donation_for_course - # TODO: Check here for minimum pricing + # Check for minimum pricing + if int(amount) < mode_info.min_price: + error_msg = _("No selected price or selected price is too low.") + return self.get(request, error=error_msg) return redirect( "{}?{}".format( @@ -75,7 +81,7 @@ class ChooseModeView(View): def get_requested_mode(self, user_choice): choices = { - "Select Audit" : "audit", - "Select Certificate" : "verified" + "Select Audit": "audit", + "Select Certificate": "verified" } return choices.get(user_choice) diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index 7f2aa01555..936a0ecc40 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -25,6 +25,11 @@

    + %if error: +
    + ${error} +
    + %endif

    Select your track:

    diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 6340e4d6bb..34a38d182a 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -104,7 +104,7 @@ def render_purchase_form_html(cart): """ return render_to_string('shoppingcart/cybersource_form.html', { 'action': get_purchase_endpoint(), - 'params': get_signed_purchase_params(params), + 'params': get_signed_purchase_params(cart), }) def get_signed_purchase_params(cart): diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 4c76251d84..60ff619c84 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -9,9 +9,10 @@ from mitxmako.shortcuts import render_to_response from django.conf import settings from django.core.urlresolvers import reverse -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import redirect from django.views.generic.base import View +from django.utils.translation import ugettext as _ from course_modes.models import CourseMode from student.models import CourseEnrollment @@ -47,14 +48,14 @@ class VerifyView(View): else: chosen_price = verify_mode.min_price.format("{:g}") 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" : verify_mode.suggested_prices.split(","), - "currency" : verify_mode.currency.upper(), - "chosen_price" : chosen_price, + "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": verify_mode.suggested_prices.split(","), + "currency": verify_mode.currency.upper(), + "chosen_price": chosen_price, } return render_to_response('verify_student/photo_verification.html', context) @@ -66,17 +67,20 @@ def create_order(request): attempt.save() course_id = request.POST['course_id'] - donation_for_course = request.session.get("donation_for_course", {}) + contribution = request.POST.get("contribution", 0) - # FIXME: When this isn't available we do...? - verified_mode = CourseMode.modes_for_course_dict(course_id)["verified"] - amount = donation_for_course.get(course_id, verified_mode.min_price) + 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 contribution < 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 - # enrollment = CourseEnrollment.create_enrollment(request.user, course_id) cart = Order.get_cart_for_user(request.user) cart.clear() - CertificateItem.add_to_order(cart, course_id, amount, 'verified') + CertificateItem.add_to_order(cart, course_id, contribution, 'verified') params = get_signed_purchase_params(cart) diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 590b399ace..0bfc2a3a49 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -37,7 +37,7 @@ .done(function(data) { $("#pay_form").submit(); }) - .fail(function() { alert("error"); }); + .fail(function(jqXhr,text_status, error_thrown) { alert(jqXhr.responseText); }); } function initSnapshotHandler(names) { From 6c647d763099a376dcd935eb9db69686457860f5 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 28 Aug 2013 17:06:52 -0400 Subject: [PATCH 095/185] Return the correct data to the backend. --- lms/djangoapps/verify_student/views.py | 2 +- .../verify_student/photo_verification.html | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 60ff619c84..5a14f5f38e 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -74,7 +74,7 @@ def create_order(request): if not verified_mode: return HttpResponseBadRequest(_("This course doesn't support verified certificates")) - if contribution < verified_mode.min_price: + if int(contribution) < 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 diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 0bfc2a3a49..ea8d85b74f 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -19,10 +19,21 @@ } 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 xhr = $.post( "create_order", { - "course_id" : "${course_id}" + "course_id" : "${course_id}", + "contribution": contribution }, function(data) { for (prop in data) { From 6fd86904f1b9115c81cf61e3aaa26efb6edb9bee Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 28 Aug 2013 17:21:38 -0400 Subject: [PATCH 096/185] Fix bug where resetting second picture would remove the first --- lms/templates/verify_student/photo_verification.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index ea8d85b74f..3a6ea9841c 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -85,7 +85,7 @@ } function reset() { - $('#face_image')[0].src = ""; + image[0].src = ""; video[0].play(); approveButton.removeClass('approved'); From 3efa803321314e8562633a2b77c9edcd68c8d91e Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 28 Aug 2013 17:27:34 -0400 Subject: [PATCH 097/185] Fix bug where we don't re-disable a button if they uncheck that the verification pictures look good. --- lms/templates/verify_student/photo_verification.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 3a6ea9841c..4146af17d7 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -135,6 +135,9 @@ if (this.checked) { $("#pay_button_frame").removeClass('disabled'); } + else { + $("#pay_button_frame").addClass('disabled'); + } }); $("#pay_button_frame").addClass('disabled'); From e7a3847e48e72f80f860bf104dcb8a0c4839106f Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 28 Aug 2013 17:43:01 -0400 Subject: [PATCH 098/185] More validation to the free-form price text box and allow for decimal places --- common/djangoapps/course_modes/views.py | 16 ++++++++++++---- lms/djangoapps/verify_student/views.py | 9 +++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index b3c3e0bbfb..f00bd62d93 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -1,3 +1,4 @@ +import decimal from django.core.urlresolvers import reverse from django.http import ( HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, Http404 @@ -63,15 +64,22 @@ class ChooseModeView(View): amount = request.POST.get("contribution") or \ request.POST.get("contribution-other-amt") or 0 - donation_for_course = request.session.get("donation_for_course", {}) - donation_for_course[course_id] = amount - request.session["donation_for_course"] = donation_for_course + 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, error=error_msg) # Check for minimum pricing - if int(amount) < mode_info.min_price: + if amount_value < mode_info.min_price: error_msg = _("No selected price or selected price is too low.") return self.get(request, error=error_msg) + donation_for_course = request.session.get("donation_for_course", {}) + donation_for_course[course_id] = donation_for_course + request.session["donation_for_course"] = donation_for_course + return redirect( "{}?{}".format( reverse('verify_student_show_requirements'), diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 5a14f5f38e..1b8e1ca56e 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -4,6 +4,7 @@ """ import json import logging +import decimal from mitxmako.shortcuts import render_to_response @@ -68,19 +69,23 @@ def create_order(request): course_id = request.POST['course_id'] contribution = request.POST.get("contribution", 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.")) 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 int(contribution) < verified_mode.min_price: + 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, contribution, 'verified') + CertificateItem.add_to_order(cart, course_id, amount, 'verified') params = get_signed_purchase_params(cart) From b2141ee7c00127c52fd01b2d0fbde104c3ce64f6 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 28 Aug 2013 17:48:17 -0400 Subject: [PATCH 099/185] Add verified cert marketing link --- common/templates/course_modes/choose.html | 2 +- lms/envs/common.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index 936a0ecc40..d8d8592793 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -100,7 +100,7 @@

    - What is an ID Verified Certificate? + What is an ID Verified Certificate?

    diff --git a/lms/envs/common.py b/lms/envs/common.py index 627a813e05..f0c735dacf 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -814,6 +814,9 @@ MKTG_URL_LINK_MAP = { 'TOS': 'tos', 'HONOR': 'honor', 'PRIVACY': 'privacy_edx', + + # Verified Certificates + 'WHAT_IS_VERIFIED_CERT' : 'verified-certificate', } ############################### THEME ################################ From db0af8a8638073c816c9e8a128a2826a5b7b6188 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 28 Aug 2013 19:21:18 -0400 Subject: [PATCH 100/185] Enabled honor certificates --- common/djangoapps/course_modes/views.py | 2 ++ common/templates/course_modes/choose.html | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index f00bd62d93..e4b115fe22 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -49,6 +49,8 @@ class ChooseModeView(View): return self.get(request, 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: diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index d8d8592793..d9b7724f51 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -73,11 +73,11 @@

    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.
    - % if "honor" in modes:
    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. +
    % endif % if "honor" in modes:
    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. -

    +

    % endif From 634d207762332132f9f4e7b9fe7b86ee2f4fa94d Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 28 Aug 2013 22:25:14 -0400 Subject: [PATCH 101/185] Make it so that auto-filling of selected price works again on confirmation screen before submitting credit card info. --- common/djangoapps/course_modes/views.py | 2 +- lms/djangoapps/verify_student/views.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index e4b115fe22..9c812a588a 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -79,7 +79,7 @@ class ChooseModeView(View): return self.get(request, error=error_msg) donation_for_course = request.session.get("donation_for_course", {}) - donation_for_course[course_id] = donation_for_course + donation_for_course[course_id] = amount_value request.session["donation_for_course"] = donation_for_course return redirect( diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 1b8e1ca56e..f442e2ea14 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -54,7 +54,10 @@ class VerifyView(View): "course_id": course_id, "course_name": course_from_id(course_id).display_name, "purchase_endpoint": get_purchase_endpoint(), - "suggested_prices": verify_mode.suggested_prices.split(","), + "suggested_prices": [ + decimal.Decimal(price) + for price in verify_mode.suggested_prices.split(",") + ], "currency": verify_mode.currency.upper(), "chosen_price": chosen_price, } From a8d7b9974308989833bcabd045f0de2cc5105c33 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 29 Aug 2013 10:13:28 -0400 Subject: [PATCH 102/185] Move verify-specific JS out of site level --- lms/templates/main.html | 3 --- lms/templates/verify_student/photo_verification.html | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lms/templates/main.html b/lms/templates/main.html index 179a84f7d3..a25e8f5261 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -92,9 +92,6 @@ <%static:js group='application'/> <%static:js group='module-js'/> - - - <%block name="js_extra"/> diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 4146af17d7..4fa982d4db 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -1,10 +1,14 @@ <%! 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="js_extra"> + + + @@ -23,9 +24,14 @@ $(document).ready(function() { @@ -33,7 +39,7 @@ $(document).ready(function() {

    Select your track:

    - + % if "audit" in modes:
    @@ -44,7 +50,7 @@ $(document).ready(function() {
    -
      +
      • @@ -69,39 +75,42 @@ $(document).ready(function() {
        Select your contribution for this course:
        -
          +
            % for price in modes["verified"].suggested_prices.split(","): -
          • +
          • % endfor +
          • +
              +
            • + + +
            • -
            • - - -
            • - -
            • - -
              - $ - - USD -
              +
            • + +
              + $ + + USD +
              +
            • +
        -
        What if I don't meet all of the requirements for financial assistance but I still want to work toward a certificate?
        +
        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.

        @@ -146,12 +155,23 @@ $(document).ready(function() {
        -
          + + +
          +

          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. View requirements

          +
          +
          % endif @@ -177,7 +197,7 @@ $(document).ready(function() {
        • -

          Have questions?

          +

          Have questions?

          diff --git a/lms/static/sass/elements/_controls.scss b/lms/static/sass/elements/_controls.scss index 752f8abcbb..59ac268f8e 100644 --- a/lms/static/sass/elements/_controls.scss +++ b/lms/static/sass/elements/_controls.scss @@ -56,6 +56,34 @@ } } +// 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; diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index f5f162b411..c52ac3e801 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -1,38 +1,79 @@ // lms - views - verification flow // ==================== -// some nasty resets and standard styles -body.register.verification-process { - font-family: 'Open Sans', sans-serif; +// 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: custom link +.custom-link { + @extend .ui-fake-link; + +} + +// MISC: expandable UI +.is-expandable { + + .title-expand { + + } + + .expandable-more { + display: block; + + &.is-hidden { + display: none; + } + } +} + +.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; - @extend .t-copy-base; + 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 - p { - margin-bottom: ($baseline*0.75); - } - dt { - margin: 0 0 .5em 0; - font-weight: bold; - } - - dd { - margin: 0 0 1em 0; - } - - dl dl { - margin: ($baseline/4) 0 0 ($baseline/2); - } // reset: forms input { @@ -42,580 +83,1128 @@ body.register.verification-process { } label { + @extend .t-weight4; + font-family: $sans-serif; font-style: normal; - font-family: 'Open Sans', sans-serif; - font-weight: 400; + color: $m-gray-d4; } - .faq { - @extend .t-copy-sub2; - - label { - font-size: 12px; - font-weight: bold; - } + // HACK: nasty override due to our bad input/button styling + button, input[type="submit"], input[type="button"] { + @include font-size(16); + @extend .t-weight3; + text-transform: none; + text-shadow: none; + letter-spacing: 0; } + // 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 { + @extend .ui-no-list; + } // ==================== - // elements: layout .content-wrapper { - background: none repeat scroll 0 0 #F7F7F7; + background: $m-gray-l4; padding-bottom: 0; } .container { - background-color: #fff; + background-color: $white; padding: ($baseline*1.5) ($baseline*1.5) ($baseline*2) ($baseline*1.5); } - // elements: common UI + // ==================== + + // elements: common copy .title { @extend .t-title5; - margin-bottom: ($baseline/2); - font-weight: 300; + @extend .t-weight1; } - .tip { - @extend .t-copy-sub2; - @include transition(color 0.15s ease-in-out 0s); - display: block; - margin-top: ($baseline/4); - color: $lighter-base-font-color; + .copy { + @extend .t-weight1; } - // ==================== - - // elements: header - .page-header { - - .title { - @include font-size(28); - @include line-height(28); - margin-bottom: $baseline; - border-bottom: 1px solid #ccc; - padding-bottom: 20px; - color: #2F73BC; - font-weight: 300; - text-transform: uppercase; - } - } - - // elements: page options - .pay-options { - list-style-type: none; - margin: 0; - padding: 0; - - li { - display: inline-block; - background-color: $light-gray; - padding: ($baseline/2) ($baseline*.75); - margin-right: ($baseline/4); - vertical-align: middle; - - &.other1 { - margin-right: -($baseline/4); - padding-right: ($baseline/4); - min-height: 25px; - } - &.other2 { - padding: ($baseline/4) ($baseline*.75) ($baseline/4) 0; - } - - label { - vertical-align: middle; - } - - input { - vertical-align: middle; - } - } - } - - // elements - controls - .m-btn-primary { - margin-bottom: 0; - padding: 0; - - a, input { - background: none; - border: none; - box-shadow: none; - color: $very-light-text; - display: block; - padding: ($baseline*.75) $baseline; - text-transform: none; - text-shadow: none; - letter-spacing: 0; - - &:hover { - text-decoration: none; - border: none; - } - } - - &.disabled, &[disabled], &.is-disabled { - background-color: #ccc; - box-shadow: none; - pointer-events: none; - - &:hover { - background: $action-primary-disabled-bg !important; // needed for IE currently - } - - } - - // NOTE: need to change this to a semantic class - &.green { - box-shadow: 0 2px 1px rgba(2,100,2,1); - background-color: rgba(0,136,1,1); - - &:hover { - box-shadow: 0 2px 1px rgba(2,100,2,1); - background-color: #029D03; - } - - } + .action-primary { + @extend .btn-primary-blue; + border: none; } + .action-confirm { + @extend .btn-primary-green; + border: none; + } // ==================== + // elements: page depth - // nav/status: progress - .progress { - @include clearfix; + // ==================== + + // elements : help + .help { + + } + + .help-item { + + .title { + @extend .hd-lv4; + margin-bottom: ($baseline/4); + } + + .copy { + @extend .copy-detail; + } + } + + .list-help { + + } + + // ==================== + + // UI: page header + .page-header { + width: flex-grid(12,12); + margin: 0 0 $baseline 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-label { + @extend .text-sr; + } + + .sts-track-value { + @extend .copy-badge; + color: $white; + background: $m-green-l2; + } + } + + .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; + + } + } + } + + // ==================== + + // UI : progress + .wrapper-progress { position: relative; - margin-bottom: $baseline; - border-bottom: 1px solid #ccc; - padding-bottom: $baseline; + margin-bottom: ($baseline*1.5); + } + + .progress-sts { + @include size(($baseline/4)); + @extend .ui-depth1; + position: absolute; + top: 43px; + left: 70px; + display: block; + width: 77%; + margin: 0 auto; + background: $m-gray-l4; + + .progress-sts-value { + width: 0%; + height: 100%; + display: block; + background: $m-green-l3; + } + } + + .progress { + width: flex-grid(12,12); + margin: 0 auto; + border-bottom: ($baseline/4) solid $m-gray-l4; .progress-steps { - margin: 0; - padding: 0; + @include clearfix(); + position: relative; + top: ($baseline/4); } .progress-step { - @extend .t-copy-sub1; + @extend .ui-depth2; position: relative; - z-index: 200; - display: inline-block; - width: 15%; - padding: ($baseline/2) 0; + width: flex-grid(2,12); + float: left; + padding: $baseline $baseline ($baseline*1.5) $baseline; text-align: center; - color: #ccc; - &.current { - color: #008801; - - .number { - border: 4px solid #008801; - color: #008801; - } - } - - &.done { - color: #777; - - .number { - border: 4px solid #777; - color: #777; - } - } - } - - .number { - height: 2em; - width: 2em; - display: block; - margin: 0 auto ($baseline/2); - border: 4px solid #ddd; - border-radius: 20px; - background-color: #fff; - line-height: 2em; - text-align: center; - color: #ccc; - } - - .mini { - height: .5em; - width: .5em; - margin-bottom: 1.5em; - } - - .progress-line, - .progress-line-done { - position: absolute; - top: 26%; - left: 8%; - height: 2px; - width: 100%; - display: inline-block; - background-color: #ddd; - } - - .progress-line-done { - width: 20%; - background-color: #008801; - } - } - - .support { - margin-top: ($baseline*2); - } - - -// ==================== - - -// VIEW: select a track - &.step-select-track { - - .select { - @include clearfix(); - - .divider { + .wrapper-step-number, .step-number, .step-name { display: block; - clear: both; - width: 60%; - margin: $baseline $baseline 0 $baseline; - border-top: 2px solid #ddd; + } - p { - @extend .t-copy-base; - position: relative; - top: -$baseline; - width: 40px; - margin: 0 auto; - padding: 0 $baseline; - background-color: #fff; - color: #aaa; - text-align: center; + .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; } } - .block { - position: relative; + .step-name { + @extend .t-title7; + @extend .t-weight4; + color: $m-gray-l1; + } + + // confirmation step w/ icon + &#progress-step5 { + + .step-number { + margin-top: ($baseline/2); + } + } + + // STATE: is completed + &.is-completed { + border-bottom: ($baseline/5) solid $m-green-l2; + + .wrapper-step-number { + border-color: $m-green-l2; + } + + .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 { + padding: 0 $baseline !important; + } + + .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*2) 0 $baseline 0; + + .wrapper-help { + float: right; + width: flex-grid(6,12); + padding: ($baseline*0.75) $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 { + + .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; + + .faq-question { + @extend .hd-lv3; + border-bottom: 1px solid $m-gray-l4; + padding-bottom: ($baseline/4); + } + + .faq-answer { + margin-bottom: ($baseline*1.25); + } + } + } + } + + .task { + @extend .ui-window; float: left; - margin: 0 $baseline ($baseline*1.5) 0; - border-top: 5px solid #32A5D9; - background-color: #eee; - padding: $baseline ($baseline*1.5) ($baseline*2) ($baseline*1.5); - width: 60%; + width: flex-grid(6,12); + margin-right: flex-gutter(); + } + .controls { + padding: ($baseline*0.75) $baseline; + background: $m-gray-l4; - &.block-cert { - border-top: 5px solid #008801; + .list-controls { + position: relative; + } - .ribbon { - background: transparent url('../images/vcert-ribbon-s.png') no-repeat 0 0; - position: absolute; - top: -($baseline*1.5); - right: $baseline; - display: block; - width: ($baseline*3); - height: ($baseline*4); + .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-primary-green; + } } } - - p, li, dl { - color: $lighter-base-font-color; - } - - .wrap-copy { - width: 60%; - display: inline-block; - vertical-align: middle; - } - - .title { - @extend .t-title4; - font-weight: bold; - } - - .m-btn-primary { + // control - redo + .control-redo { position: absolute; - bottom: ($baseline*1.5); - right: ($baseline*1.5); + left: ($baseline/2); } + // control - take/do + .control-do { + left: 45%; + } - } - - hr { - margin: 1em 0 2em 0; - } - - .more { - margin-top: ($baseline/2); - border-top: 1px solid #ccc; - } - - .tips { - float: right; - width: 32%; - - p { - @extend .t-copy-sub1; + // 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-gray; + } + } + } + + .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-gray; + } + } + } + + // 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); + + &:last-child { + margin-right: 0; + } + } + + .contribution-option-other1 label, .contribution-option-other2 label { + @extend .text-sr; + } + } + } + } + } + + } + + // UI: camera states + .cam { + + .placeholder-cam { + @include size(500px 375px); + margin: $baseline auto; + background: $m-blue-d1; + } + + .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); + margin: $baseline 0; + 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; + } + } + + // ==================== + + // 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; + } + + 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 { + position: relative; + top: -($baseline/4); + width: ($baseline*3); + padding: ($baseline/4) ($baseline/2); + } + } + } + + // ==================== + + // 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 { + + .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; + } + } + + .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: $m-green-l1; + 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-top: $baseline; + 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-primary-green; + } + + .action-intro { + + } + } + + .help-register { + width: flex-grid(4,12); + + .title { + @extend .hd-lv4; + @extend .t-weight4; + margin-bottom: ($baseline/2); + } + + .copy { + @extend .copy-detail; + } + } + + // progress indicator + .progress-sts-value { + width: 0%; + } + + .wrapper-content-supplementary .content-supplementary .help-item { + width: flex-grid(3,12); + } + + // 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*3); + padding: ($baseline/4) ($baseline/2); + } + + .field-group-other { + + .contribution-option { + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + + &:last-child { + margin-right: 0; + } + } + + .contribution-option-other1 label, .contribution-option-other2 label { + @extend .text-sr; + } + } + } + } // VIEW: requirements &.step-requirements { - .section-head .title { - @extend .t-title4; - display: inline-block; - margin: ($baseline/4) 0; + // progress indicator + .progress-sts-value { + width: 0%; } - .reqs { - margin: $baseline $baseline ($baseline*1.5) $baseline; - } + .list-reqs { + @include clearfix(); + width: flex-grid(12,12); - .req { - height: 13em; - width: 27%; - display: inline-block; - margin-right: 1%; - border: 1px solid #ddd; - padding: $baseline 2%; - text-align: center; - vertical-align: top; + .req { + @extend .ui-window; + width: flex-grid(4,12); + min-height: ($baseline*15); + float: left; + margin-right: flex-gutter(); + text-align: center; - [class^="icon-"] { - display: block; - width: 150px; - margin: $baseline auto; - font-size: 75px; - - &.icon-id { - background: transparent url('../images/icon-id.png') no-repeat center center; - height: 70px; + &:last-child { + margin-right: 0; } - } - - } - - .next-step { - float: right; - - .tip { - margin-top: $baseline; - } - - } - - hr { - margin: ($baseline*2); - } - - .steps-section { - - .section-head { - margin-bottom: ($baseline); - } - - .step { - width: 60%; - margin-left: 3%; - padding: ($baseline) ($baseline*1.5); - - .step-title { + .title { @extend .t-title5; - font-weight: bold; + @extend .t-weight4; + padding: $baseline; + border-bottom: 1px solid $m-green-l2; + background: $m-green-l4; + } - .number { - @extend .t-title6; - height: 2em; - width: 2em; + .placeholder-art { + position: relative; display: inline-block; - margin: 0 ($baseline/2) 0 0; - border: 3px solid #000; - border-radius: 30px; - font-weight: bold; - line-height: 2em; - text-align: center; - color: #000; + margin: $baseline 0 ($baseline/2) 0; + padding: $baseline; + background: $m-green-l2; + 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: $m-green-l2; + padding: 3px 5px; + } } .copy { + padding: ($baseline/2) $baseline; + } + + .copy-super, .copy-sub { + display: block; + } + + .copy-super { @extend .t-copy-base; - margin-left: 65px; + margin-bottom: ($baseline/2); + color: $m-green; + } + + .copy-sub { + @extend .t-copy-sub2; + } + + .actions { + padding: ($baseline/2) $baseline; } } } } - - // ==================== - - // VIEW: take and review photos &.step-photos { - // TEMP: for dev placement only - .wrapper-cam, - .wrapper-photo { - height: 375px; - width: 500px; - background-color: #eee; - position: relative; + } - p { - position: absolute; - top: 40%; - left: 40%; - color: #ccc; - } + // VIEW: take cam photo + &.step-photos-cam { + + // progress indicator + .progress-sts-value { + width: 20%; } + } - .block-photo { - @include clearfix(); - background-color: $white; + // VIEW: take id photo + &.step-photos-id { - .title { - @extend .t-title4; + // progress indicator + .progress-sts-value { + width: 40%; + } + } + + // VIEW: review photos + &.step-review { + + .nav-wizard { + + .help-inline { + width: flex-grid(4,12); + margin-top: 0 } - .wrapper-up, - .wrapper-down { - @include clearfix(); - } + .wizard-steps { + float: right; + width: flex-grid(8,12); - .cam { - width: 500px; - float: left; - padding-right: $baseline; - } + .wizard-step { + width: flex-grid(4,8); + margin-right: flex-gutter(); + display: inline-block; + vertical-align: middle; - .photo-controls { - background-color: #ddd; - - .controls-list { - @include clearfix(); - position: relative; - margin: 0; - padding: ($baseline*.25) ($baseline*.75); - list-style-type: none; - height: 60px; - - .control { - display: inline-block; - - .action { - @extend .button-primary; - display: block; - background-color: $blue; - color: $white; - border: 3px solid #1A74A0; - border-radius: 40px; - padding: 10px 5px; - text-align: center; - - i { - padding: ($baseline*.25) ($baseline*.5); - display: block; - } - - &:hover { - - } - - } - - - &.is-hidden { - visibility: hidden; - } - - &.is-shown { - visibility: visible; - } - - - &.control-do { - position: relative; - left: 45%; - } - - &.control-redo { - position: absolute; - left: ($baseline/2); - } - - &.control-approve { - position: absolute; - right: ($baseline/2); - } - - &.approved a { - background-color: $green; - } + &:last-child { + margin-right: 0; } } } - .faq { - width: 45%; - float: left; - padding-right: $baseline; + .step-match { + + label { + @extend .t-copy-sub1; + } + } + + .step-proceed { + } } - .photo-tips { - @extend .t-copy-sub1; - width: 45%; - float: left; - - .title { - @extend .t-title5; - border-bottom: 1px solid #ddd; - padding-bottom: ($baseline/4); - font-weight: bold; - } - + // progress indicator + .progress-sts-value { + width: 60%; } + } - .next-step { - margin-top: $baseline; + // VIEW: take and review photos + &.step-confirmation { - .tip { - margin-top: $baseline; - } - } - - .support { - margin-top: ($baseline*2); - } - - .review-photo { - width: 500px; - float: left; - - .title { - @extend .t-title5; - margin-top: $baseline; - } - } - - #review-facephoto { - margin-right: $baseline; + // progress indicator + .progress-sts-value { + width: 100%; } } } diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index b2f41953fd..831f763118 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -1,8 +1,8 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> <%inherit file="../main.html" /> - <%block name="bodyclass">register verification-process step-photos +<%block name="title">${("Register for [Course Name] | Verification")} <%block name="js_extra"> @@ -168,9 +168,14 @@ @@ -178,41 +183,43 @@

          Your Progress

          - - - -
          1. - 0 + 0 Intro
          2. -
          3. - 1 +
          4. + 1 Current Step: Take Photo
          5. - 2 - Intro + 2 + Take ID Photo
          6. - 3 - Intro + 3 + Review
          7. - 4 - Intro + 4 + Make Payment
          8. - 5 + + + Confirmation
          + + + +
          @@ -220,7 +227,7 @@
        From 8c107569e9ea10e31f69edfca8a77c92ea924d04 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 29 Aug 2013 16:30:53 -0400 Subject: [PATCH 107/185] Fix cart submission --- lms/djangoapps/verify_student/views.py | 1 + .../verify_student/photo_verification.html | 28 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index f442e2ea14..5ee24893d8 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -78,6 +78,7 @@ def create_order(request): return HttpResponseBadRequest(_("Selected price is not valid number.")) 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")) diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index d7a001f3d9..dcf5e10525 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -53,7 +53,9 @@ .done(function(data) { $("#pay_form").submit(); }) - .fail(function(jqXhr,text_status, error_thrown) { alert(jqXhr.responseText); }); + .fail(function(jqXhr,text_status, error_thrown) { + alert(jqXhr.responseText); + }); } function initSnapshotHandler(names) { @@ -136,16 +138,16 @@ $(document).ready(function() { $(".carousel-nav").addClass('sr'); $("#pay_button").click(submitToPaymentProcessing); - $("#confirm_pics_good").click(function() { - if (this.checked) { - $("#pay_button_frame").removeClass('disabled'); - } - else { - $("#pay_button_frame").addClass('disabled'); - } - }); - - $("#pay_button_frame").addClass('disabled'); + // $("#confirm_pics_good").click(function() { + // if (this.checked) { + // $("#pay_button_frame").removeClass('disabled'); + // } + // else { + // $("#pay_button_frame").addClass('disabled'); + // } + // }); + // + // $("#pay_button_frame").addClass('disabled'); initVideoCapture(); initSnapshotHandler(["photo_id", "face"]); @@ -478,8 +480,8 @@
        - - + +
        From db8a810009fa539db4377aa592f9c627ae94b4c2 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 29 Aug 2013 16:43:02 -0400 Subject: [PATCH 108/185] Verified: cleans up extraneous reg selection markup and expandable UI --- .../templates/course_modes/_contribution.html | 4 +- common/templates/course_modes/choose.html | 25 ++--- lms/static/sass/views/_verification.scss | 93 ++++++++++++++----- 3 files changed, 80 insertions(+), 42 deletions(-) diff --git a/common/templates/course_modes/_contribution.html b/common/templates/course_modes/_contribution.html index 3fd5a0ef3a..f63e3b2e4d 100644 --- a/common/templates/course_modes/_contribution.html +++ b/common/templates/course_modes/_contribution.html @@ -1,8 +1,8 @@
          % for price in suggested_prices:
        • - -
        • -
        • -

          Verified Certificate of Achievement Requirememts

          -
          -

          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?

          diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index c52ac3e801..207336f5ae 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -17,11 +17,6 @@ padding: ($baseline*0.75) $baseline; } -// MISC: custom link -.custom-link { - @extend .ui-fake-link; - -} // MISC: expandable UI .is-expandable { @@ -30,11 +25,48 @@ } - .expandable-more { - display: block; + .expandable-icon { + @include transition(all 0.25s ease-in-out 0s); + display: inline-block; + vertical-align: middle; + margin-left: ($baseline/4); + } - &.is-hidden { + .expandable-area { + @include transition(opacity 0.25s ease-in-out 0s); + } + + // STATE: active + &.is-ready { + + .title-expand { + @extend .ui-fake-link; + color: $m-blue-d2; + border-bottom: 1px dotted transparent; + + &:hover { + color: $m-blue-d2; + border-bottom: 1px dotted $m-blue-d2; + } + } + + .expandable-area { display: none; + opacity: 0.0; + } + } + + // STATE: expanded + &.is-expanded { + + .expandable-icon { + @include transform(rotate(-180deg)); + @include transform-origin(50% 50%); + } + + .expandable-area { + display: block; + opacity: 1.0; } } } @@ -408,16 +440,6 @@ // help - faq .list-faq { margin-bottom: $baseline; - - .faq-question { - @extend .hd-lv3; - border-bottom: 1px solid $m-gray-l4; - padding-bottom: ($baseline/4); - } - - .faq-answer { - margin-bottom: ($baseline*1.25); - } } } } @@ -765,6 +787,7 @@ .label, label { margin-bottom: 0; + padding: 6px 0; } input { @@ -790,8 +813,6 @@ // specific fields #contribution-other-amt { - position: relative; - top: -($baseline/4); width: ($baseline*3); padding: ($baseline/4) ($baseline/2); } @@ -800,6 +821,22 @@ // ==================== + // 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 { @@ -963,6 +1000,18 @@ .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 { @@ -984,10 +1033,6 @@ width: 0%; } - .wrapper-content-supplementary .content-supplementary .help-item { - width: flex-grid(3,12); - } - // contribution selection .field-certificate-contribution { margin: $baseline 0; From 992b36e7719fc953e0e0cff1260ded654d8bd6dd Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 29 Aug 2013 16:57:54 -0400 Subject: [PATCH 109/185] Verified: adds initial registration choice error styling --- lms/static/sass/views/_verification.scss | 34 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 207336f5ae..e194eb9af1 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -179,11 +179,7 @@ // ==================== - // elements : help - .help { - - } - + // UI: help .help-item { .title { @@ -196,8 +192,34 @@ } } - .list-help { + // ==================== + // UI: error + .wrapper-msg-error { + width: flex-grid(12,12); + border-radius: ($baseline/5); + margin-bottom: $baseline; + padding: $baseline ($baseline*1.25); + box-shadow: 0 2px 1px 0 tint($red,90%); + background: $red; + + .msg-error { + @include clearfix(); + + .title { + @extend .t-title5; + @extend .t-weight4; + margin-bottom: ($baseline/2); + border-bottom: ($baseline/10) solid tint($red,35%); + padding-bottom: ($baseline/2); + color: tint($red,90%); + } + + .copy { + @extend .t-copy-base; + color: tint($red,65%); + } + } } // ==================== From fc52fab23152aaaff12c457c3d15f405e3416e24 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 29 Aug 2013 16:45:50 -0400 Subject: [PATCH 110/185] Go skip verification step if student is already verified. --- common/djangoapps/course_modes/views.py | 8 ++++ lms/djangoapps/verify_student/models.py | 4 +- lms/djangoapps/verify_student/urls.py | 6 +++ lms/djangoapps/verify_student/views.py | 52 ++++++++++++++++++---- lms/templates/verify_student/verified.html | 43 ++++++++++++++++++ 5 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 lms/templates/verify_student/verified.html diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 9c812a588a..d55943f354 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -16,6 +16,7 @@ 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): @@ -81,6 +82,13 @@ class ChooseModeView(View): 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( + "{}?{}".format( + reverse('verify_student_verified'), + urlencode(dict(course_id=course_id)) + ) + ) return redirect( "{}?{}".format( diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 7d9b9799c8..f90ceec259 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -175,7 +175,7 @@ class PhotoVerification(StatusModel): return cls.objects.filter( user=user, status="approved", - created_at__lte=earliest_allowed_date + created_at__gte=earliest_allowed_date ).exists() @classmethod @@ -191,7 +191,7 @@ class PhotoVerification(StatusModel): return cls.objects.filter( user=user, status__in=valid_statuses, - created_at__lte=earliest_allowed_date + created_at__gte=earliest_allowed_date ).exists() @classmethod diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index f5fc5d2f7e..83c2812f91 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -35,6 +35,12 @@ urlpatterns = patterns( name="verify_student_verify" ), + url( + r'^verified', + views.VerifiedView.as_view(), + name="verify_student_verified" + ), + url( r'^create_order', views.create_order, diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 5ee24893d8..7e68c11251 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -14,6 +14,7 @@ from django.http import HttpResponse, HttpResponseBadRequest 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 course_modes.models import CourseMode from student.models import CourseEnrollment @@ -31,10 +32,16 @@ class VerifyView(View): def get(self, request): """ """ + course_id = request.GET['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): - progress_state = "payment" + return redirect( + "{}?{}".format( + reverse('verify_student_verified'), + urlencode(dict(course_id=course_id)) + ) + ) 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 @@ -42,7 +49,6 @@ class VerifyView(View): # bookkeeping-wise just to start over. progress_state = "start" - course_id = request.GET['course_id'] 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] @@ -65,13 +71,43 @@ class VerifyView(View): 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 + """ + def get(self, request): + """ + Handle the case where we have a get request + """ + course_id = request.GET['course_id'] + 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) + + def create_order(request): + """ + Submit PhotoVerification and create a new Order for this verified cert + """ attempt = SoftwareSecurePhotoVerification(user=request.user) - attempt.status = "pending" + attempt.status = "ready" attempt.save() course_id = request.POST['course_id'] - contribution = request.POST.get("contribution", 0) + donation_for_course = request.session.get('donation_for_course', {}) + 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: @@ -98,19 +134,19 @@ def create_order(request): def show_requirements(request): """This might just be a plain template without a view.""" - context = { "course_id" : request.GET.get("course_id") } + context = { "course_id": request.GET.get("course_id") } return render_to_response("verify_student/show_requirements.html", context) def face_upload(request): - context = { "course_id" : "edX/Certs101/2013_Test" } + context = { "course_id": "edX/Certs101/2013_Test" } return render_to_response("verify_student/face_upload.html", context) def photo_id_upload(request): - context = { "course_id" : "edX/Certs101/2013_Test" } + context = { "course_id": "edX/Certs101/2013_Test" } return render_to_response("verify_student/photo_id_upload.html", context) def final_verification(request): - context = { "course_id" : "edX/Certs101/2013_Test" } + context = { "course_id": "edX/Certs101/2013_Test" } return render_to_response("verify_student/final_verification.html", context) def show_verification_page(request): diff --git a/lms/templates/verify_student/verified.html b/lms/templates/verify_student/verified.html new file mode 100644 index 0000000000..0fece909ab --- /dev/null +++ b/lms/templates/verify_student/verified.html @@ -0,0 +1,43 @@ +<%! 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="js_extra"> + + +<%block name="content"> +

          You have already been verified. Hooray!

          +

          You have decided to pay $${chosen_price}

          + + + + + + + From c21cde37a0c71d1b823ca7f39b7bf32905aff11a Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 30 Aug 2013 10:38:48 -0400 Subject: [PATCH 111/185] Fix some urls on the choose mode page --- common/templates/course_modes/choose.html | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index a728a9f7bc..8fd9b71aaf 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -135,9 +135,6 @@ $(document).ready(function() {
          From 94def0aab5796f287b68795332b6e48d8606fba7 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 30 Aug 2013 11:27:51 -0400 Subject: [PATCH 112/185] Base assets for flash camera capture --- lms/static/js/vendor/jpegcam/webcam.min.js | 12 ++++++++ lms/static/js/vendor/jpegcam/webcam.swf | Bin 0 -> 6141 bytes .../verify_student/photo_verification.html | 27 +++++++++++++----- 3 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 lms/static/js/vendor/jpegcam/webcam.min.js create mode 100755 lms/static/js/vendor/jpegcam/webcam.swf diff --git a/lms/static/js/vendor/jpegcam/webcam.min.js b/lms/static/js/vendor/jpegcam/webcam.min.js new file mode 100644 index 0000000000..f1118ef9be --- /dev/null +++ b/lms/static/js/vendor/jpegcam/webcam.min.js @@ -0,0 +1,12 @@ +/* JPEGCam v1.0.11 *//* Webcam library for capturing JPEG images and submitting to a server *//* Copyright (c) 2008 - 2009 Joseph Huckaby *//* Licensed under the GNU Lesser Public License *//* http://www.gnu.org/licenses/lgpl.html *//* Usage: + + Take Snapshot +*/// Everything is under a 'webcam' Namespace +window.webcam={version:"1.0.11",ie:!!navigator.userAgent.match(/MSIE/),protocol:location.protocol.match(/https/i)?"https":"http",callback:null,swf_url:"webcam.swf",shutter_url:"shutter.mp3",api_url:"",loaded:false,quality:90,shutter_sound:true,stealth:true,hooks:{onLoad:null,onAllow:null,onComplete:null,onError:null},set_hook:function(a,b){this.hooks[a]=b},fire_hook:function(a,b){if(this.hooks[a]){if(typeof this.hooks[a]==="function"){this.hooks[a](b)}else if(typeof this.hooks[a]==="array"){this.hooks[a][0][this.hooks[a][1]](b)}else if(window[this.hooks[a]]){window[this.hooks[a]](b)}return true}return false},set_api_url:function(a){this.api_url=a},set_swf_url:function(a){this.swf_url=a},get_html:function(a,b,c,d){if(!c){c=a}if(!d){d=b}var e="";var f="shutter_enabled="+(this.shutter_sound?1:0)+"&shutter_url="+encodeURIComponent(this.shutter_url)+"&width="+a+"&height="+b+"&server_width="+c+"&server_height="+d;if(this.ie){e+=''}else{e+=''}this.loaded=false;return e},get_movie:function(){if(!this.loaded){return false}var a=document.getElementById("webcam_movie");if(!a){return false}return a},set_stealth:function(a){this.stealth=a},snap:function(a,b,c){if(b){this.set_hook("onComplete",b)}if(a){this.set_api_url(a)}if(typeof c!=="undefined"){this.set_stealth(c)}this.get_movie()._snap(this.api_url,this.quality,this.shutter_sound?1:0,this.stealth?1:0)},freeze:function(){this.get_movie()._snap("",this.quality,this.shutter_sound?1:0,0)},upload:function(a,b){if(b){this.set_hook("onComplete",b)}if(a){this.set_api_url(a)}this.get_movie()._upload(this.api_url)},reset:function(){this.get_movie()._reset()},configure:function(a){if(!a){a="camera"}this.get_movie()._configure(a)},set_quality:function(a){this.quality=a},set_shutter_sound:function(a,b){this.shutter_sound=a;this.shutter_url=b?b:"shutter.mp3"},flash_notify:function(a,b){switch(a){case"security":var c=b=="Camera.Unmuted";this.fire_hook("onAllow",c);break;case"flashLoadComplete":this.loaded=true;this.fire_hook("onLoad",b);break;case"error":if(!this.fire_hook("onError",b)){}break;case"success":this.fire_hook("onComplete",b.toString());break;default:break}}} \ No newline at end of file diff --git a/lms/static/js/vendor/jpegcam/webcam.swf b/lms/static/js/vendor/jpegcam/webcam.swf new file mode 100755 index 0000000000000000000000000000000000000000..c21b7153556e960964efa516b22f4dd1e85f0740 GIT binary patch literal 6141 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 P_Wyq(I}!aKI&bj}|8?Ge literal 0 HcmV?d00001 diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index dcf5e10525..affa287b22 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -7,20 +7,22 @@ <%block name="title">${("Register for [Course Name] | Verification")} <%block name="js_extra"> - - - - + + + @@ -230,7 +243,7 @@
          -
          +

          From c96a29d77aec8b049de00caca1d82dc5cf3297a7 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 30 Aug 2013 12:56:47 -0400 Subject: [PATCH 113/185] Move course_id into the urls. --- common/djangoapps/course_modes/urls.py | 2 +- common/djangoapps/course_modes/views.py | 24 ++++++-------- common/djangoapps/student/views.py | 5 +-- .../verify_student/tests/test_views.py | 2 +- lms/djangoapps/verify_student/urls.py | 8 ++--- lms/djangoapps/verify_student/views.py | 32 +++++++++---------- .../verify_student/photo_verification.html | 2 +- .../verify_student/show_requirements.html | 2 +- 8 files changed, 33 insertions(+), 44 deletions(-) diff --git a/common/djangoapps/course_modes/urls.py b/common/djangoapps/course_modes/urls.py index 80ce7d4bda..47e7f04c40 100644 --- a/common/djangoapps/course_modes/urls.py +++ b/common/djangoapps/course_modes/urls.py @@ -5,5 +5,5 @@ from course_modes import views urlpatterns = patterns( '', - url(r'^choose', views.ChooseModeView.as_view(), name="course_modes_choose"), + 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 d55943f354..2b4d77544a 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -21,8 +21,7 @@ from verify_student.models import SoftwareSecurePhotoVerification class ChooseModeView(View): @method_decorator(login_required) - def get(self, request, error=None): - course_id = request.GET.get("course_id") + def get(self, request, course_id, error=None): modes = CourseMode.modes_for_course_dict(course_id) context = { "course_id": course_id, @@ -38,8 +37,7 @@ class ChooseModeView(View): return render_to_response("course_modes/choose.html", context) - def post(self, request): - course_id = request.GET.get("course_id") + def post(self, request, course_id): user = request.user # This is a bit redundant with logic in student.views.change_enrollement, @@ -47,7 +45,7 @@ class ChooseModeView(View): course = course_from_id(course_id) if not has_access(user, course, 'enroll'): error_msg = _("Enrollment is closed") - return self.get(request, error=error_msg) + 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"): @@ -72,29 +70,25 @@ class ChooseModeView(View): 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, error=error_msg) + 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, error=error_msg) + 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( - "{}?{}".format( - reverse('verify_student_verified'), - urlencode(dict(course_id=course_id)) - ) + reverse('verify_student_verified', + kwargs={'course_id': course_id}) ) return redirect( - "{}?{}".format( - reverse('verify_student_show_requirements'), - urlencode(dict(course_id=course_id)) - ) + reverse('verify_student_show_requirements', + kwargs={'course_id': course_id}), ) def get_requested_mode(self, user_choice): diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4ee8f61358..9173eb8224 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -375,10 +375,7 @@ def change_enrollment(request): available_modes = CourseMode.modes_for_course(course_id) if len(available_modes) > 1: return HttpResponse( - "{}?{}".format( - reverse("course_modes_choose"), - urlencode(dict(course_id=course_id)) - ) + reverse("course_modes_choose", kwargs={'course_id': course_id}) ) org, course_num, run = course_id.split("/") diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 0d37aa0efd..71d94735ff 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -21,7 +21,7 @@ from student.tests.factories import UserFactory class StartView(TestCase): def start_url(course_id=""): - return "/verify_student/start?course_id={0}".format(urllib.quote(course_id)) + return "/verify_student/{0}".format(urllib.quote(course_id)) def test_start_new_verification(self): """ diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 83c2812f91..606d96a785 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -24,19 +24,19 @@ urlpatterns = patterns( # The above are what we did for the design mockups, but what we're really # looking at now is: url( - r'^show_requirements', + r'^show_requirements/(?P[^/]+/[^/]+/[^/]+)$', views.show_requirements, name="verify_student_show_requirements" ), url( - r'^verify', + r'^verify/(?P[^/]+/[^/]+/[^/]+)$', views.VerifyView.as_view(), name="verify_student_verify" ), url( - r'^verified', + r'^verified/(?P[^/]+/[^/]+/[^/]+)$', views.VerifiedView.as_view(), name="verify_student_verified" ), @@ -48,7 +48,7 @@ urlpatterns = patterns( ), url( - r'^show_verification_page', + 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 index 7e68c11251..a0feeafd50 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -10,11 +10,12 @@ from mitxmako.shortcuts import render_to_response from django.conf import settings from django.core.urlresolvers import reverse -from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect 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 course_modes.models import CourseMode from student.models import CourseEnrollment @@ -29,19 +30,15 @@ log = logging.getLogger(__name__) class VerifyView(View): - def get(self, request): + def get(self, request, course_id): """ """ - course_id = request.GET['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( - "{}?{}".format( - reverse('verify_student_verified'), - urlencode(dict(course_id=course_id)) - ) - ) + reverse('verify_student_verified', + kwargs={'course_id': course_id})) 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 @@ -76,11 +73,10 @@ class VerifiedView(View): View that gets shown once the user has already gone through the verification flow """ - def get(self, request): + def get(self, request, course_id): """ Handle the case where we have a get request """ - course_id = request.GET['course_id'] 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] @@ -131,10 +127,12 @@ def create_order(request): return HttpResponse(json.dumps(params), content_type="text/json") - -def show_requirements(request): - """This might just be a plain template without a view.""" - context = { "course_id": request.GET.get("course_id") } +@login_required +def show_requirements(request, course_id): + """ + Show the requirements necessary for + """ + context = { "course_id": course_id } return render_to_response("verify_student/show_requirements.html", context) def face_upload(request): @@ -175,7 +173,7 @@ def enroll(user, course_id, mode_slug): # to a page that lets them choose which mode they want. if len(available_modes) > 1: return HttpResponseRedirect( - reverse('choose_enroll_mode', course_id=course_id) + reverse('choose_enroll_mode', kwargs={'course_id': course_id}) ) # Otherwise, we use the only mode that's supported... else: @@ -188,11 +186,11 @@ def enroll(user, course_id, mode_slug): return HttpResponseRedirect(reverse('dashboard')) if mode_slug == "verify": - if SoftwareSecureVerification.has_submitted_recent_request(user): + if SoftwareSecurePhotoVerification.has_submitted_recent_request(user): # Capture payment info # Create an order # Create a VerifiedCertificate order item - return HttpResponse.Redirect(reverse('payment')) + return HttpResponse.Redirect(reverse('verified')) # There's always at least one mode available (default is "honor"). If they diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index affa287b22..2853d2bc55 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -518,7 +518,7 @@
        • Change your mind?

          -

          You can always Audit the course for free without verifying.

          +

          You can always Audit the course for free without verifying.

        diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html index 7fa217917f..73a560b7ec 100644 --- a/lms/templates/verify_student/show_requirements.html +++ b/lms/templates/verify_student/show_requirements.html @@ -124,7 +124,7 @@
        1. - +
        From b5feb0748f7a207940c7fa23504a21610320bbe2 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 30 Aug 2013 13:44:49 -0400 Subject: [PATCH 114/185] Add in a hook to indicate whether or not the logged in user is active --- lms/djangoapps/verify_student/views.py | 2 +- lms/templates/verify_student/show_requirements.html | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index a0feeafd50..d8788b5c41 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -132,7 +132,7 @@ def show_requirements(request, course_id): """ Show the requirements necessary for """ - context = { "course_id": course_id } + context = { "course_id": course_id, "is_not_active": not request.user.is_active} return render_to_response("verify_student/show_requirements.html", context) def face_upload(request): diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html index 73a560b7ec..3468af7b17 100644 --- a/lms/templates/verify_student/show_requirements.html +++ b/lms/templates/verify_student/show_requirements.html @@ -20,6 +20,9 @@ + %if is_not_active: +

        I AM NOT ACTIVE.

        + %endif
        From cd479caa7883bfcecddafd97edd2a005643bccbe Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 30 Aug 2013 13:58:36 -0400 Subject: [PATCH 115/185] Make sure users are logged in on the verified cert path --- common/djangoapps/course_modes/views.py | 2 +- lms/djangoapps/verify_student/views.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 2b4d77544a..fcf1721d52 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -36,7 +36,7 @@ class ChooseModeView(View): return render_to_response("course_modes/choose.html", context) - + @method_decorator(login_required) def post(self, request, course_id): user = request.user diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index d8788b5c41..9abcc16a32 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -13,6 +13,7 @@ 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 @@ -30,6 +31,7 @@ log = logging.getLogger(__name__) class VerifyView(View): + @method_decorator(login_required) def get(self, request, course_id): """ """ @@ -73,6 +75,7 @@ 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 @@ -93,6 +96,7 @@ class VerifiedView(View): 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 From e9a0755e5dd0d635142c0b5c0bd671b6cc5ca1fc Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Fri, 30 Aug 2013 13:29:13 -0400 Subject: [PATCH 116/185] revises and transfers verification-only typography Sass extends --- lms/static/sass/elements/_typography.scss | 121 ---------------------- lms/static/sass/views/_verification.scss | 95 +++++++++++++++++ 2 files changed, 95 insertions(+), 121 deletions(-) diff --git a/lms/static/sass/elements/_typography.scss b/lms/static/sass/elements/_typography.scss index d269bfa721..884f1651ef 100644 --- a/lms/static/sass/elements/_typography.scss +++ b/lms/static/sass/elements/_typography.scss @@ -194,124 +194,3 @@ .t-weight5 { font-weight: 700; } - -// ==================== - -// application: canned headings -.hd-lv1 { - @extend .t-title1; - @extend .t-weight1; - color: $m-gray-d4; - margin: 0 0 ($baseline*2) 0; -} - -.hd-lv1-alt { - @extend .t-title2; - @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-lv2-alt { - @extend .t-title4; - @extend .t-weight1; - margin: 0 0 ($baseline*0.75) 0; - border-bottom: 1px solid $m-gray-t1; - padding-bottom: ($baseline/2); - color: $m-blue-d1; - text-transform: uppercase; -} - -.hd-lv3 { - @extend .t-title6; - @extend .t-weight4; - margin: 0 0 ($baseline/4) 0; - color: $m-gray-d4; -} - -.hd-lv3-alt { - @extend .t-title6; - @extend .t-weight3; - 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-lv4-alt { - @extend .t-title6; - @extend .t-weight4; - margin: 0 0 ($baseline) 0; - color: $m-gray-d4; - text-transform: uppercase; -} - -.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: $m-blue-d2; - } -} - -.copy-badge { - @extend .t-title8; - @extend .t-weight5; - border-radius: ($baseline/5); - padding: ($baseline/2) $baseline; - text-transform: uppercase; -} - diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index e194eb9af1..fa447d33e6 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -1,6 +1,95 @@ // 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: $m-blue-d2; + } +} + +.copy-badge { + @extend .t-title8; + @extend .t-weight5; + border-radius: ($baseline/5); + padding: ($baseline/2) $baseline; + text-transform: uppercase; +} + +// ==================== + // MISC: extends - UI - window .ui-window { @include clearfix(); @@ -11,12 +100,15 @@ 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 { @@ -71,6 +163,9 @@ } } +// ==================== + +// VIEW: all verification steps .register.verification-process { // reset: box-sizing (making things so right its scary) From 248b5a9411f103f4251a240ee5092a74b6b1f0d7 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Fri, 30 Aug 2013 14:27:39 -0400 Subject: [PATCH 117/185] Verified: visually cleans up - UI spacing, error styling, registering for badge --- common/templates/course_modes/choose.html | 26 ++++--- lms/static/sass/views/_verification.scss | 73 +++++++++++++------ .../verify_student/photo_verification.html | 5 +- .../verify_student/show_requirements.html | 5 +- 4 files changed, 72 insertions(+), 37 deletions(-) diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index 8fd9b71aaf..ebe86ff8f8 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -28,18 +28,18 @@ $(document).ready(function() { You are registering for ${course_id} - - Registering as: - ID Verified - %if error:
        + +
        -

        Sorry, there was an error with your registration

        -
        ${error}
        +

        Sorry, there was an error when trying to register you

        +
        +

        ${error}

        +
        %endif @@ -75,19 +75,27 @@ $(document).ready(function() {
        -

        Certificate of Achievement

        +

        Certificate of Achievement — ID Verified

        -

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

        +

        Sign up and work toward a verified Certificate of Achievement.

        Select your contribution for this course:
        + %if error: +
        +
        +

        ${error}

        +
        +
        + %endif + <%include file="_contribution.html" args="suggested_prices=suggested_prices, currency=currency, chosen_price=chosen_price"/> From 8f6c07fd93a03230aeb9785154534276be6031d7 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 2 Sep 2013 22:47:45 -0400 Subject: [PATCH 126/185] Verification: addresses regression of missing 'registering as:' copy --- lms/templates/verify_student/photo_verification.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index d10d150cb6..d99a16e099 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -24,7 +24,7 @@ - ${_("Registering as:")} + ${_("Registering as:")} ${_("ID Verified")} From a52ca3639bca6fda4a7791a9df96c33c906d5a29 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 21 Aug 2013 11:35:12 -0400 Subject: [PATCH 127/185] Initial BDD spec for certificates workflow --- .../djangoapps/course_modes/tests/__init__.py | 0 .../course_modes/tests/factories.py | 13 ++ .../{tests.py => tests/test_models.py} | 0 common/djangoapps/terrain/factories.py | 9 + .../courseware/features/certificates.feature | 59 +++++ .../courseware/features/certificates.py | 214 ++++++++++++++++++ 6 files changed, 295 insertions(+) create mode 100644 common/djangoapps/course_modes/tests/__init__.py create mode 100644 common/djangoapps/course_modes/tests/factories.py rename common/djangoapps/course_modes/{tests.py => tests/test_models.py} (100%) create mode 100644 lms/djangoapps/courseware/features/certificates.feature create mode 100644 lms/djangoapps/courseware/features/certificates.py 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/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/lms/djangoapps/courseware/features/certificates.feature b/lms/djangoapps/courseware/features/certificates.feature new file mode 100644 index 0000000000..988968e938 --- /dev/null +++ b/lms/djangoapps/courseware/features/certificates.feature @@ -0,0 +1,59 @@ +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 The course is added to my cart + And I view 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 + And I receive an email confirmation + And I see that I am registered for a verified certificate course on my dashboard + + 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 | + + 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. + + Scenario: I can return to the verify flow + Given I have submitted photos + When I leave the flow and return + I see the payment page + + + # Design not yet finalized + #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..a18e01a92e --- /dev/null +++ b/lms/djangoapps/courseware/features/certificates.py @@ -0,0 +1,214 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from lettuce.django import django_url +from course_modes.models import CourseMode +from selenium.common.exceptions import WebDriverException + +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.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('li.current#progress-step0') + + +@step(u'I should see the course on my dashboard$') +def should_see_the_course_on_my_dashboard(step): + course_css = 'article.my-course' + assert world.is_css_present(course_css) + + +@step(u'I go to step "([^"]*)"$') +def goto_next_step(step, step_num): + btn_css = { + '1': 'p.m-btn-primary a', + '2': '#face_next_button a.next', + '3': '#photo_id_next_button a.next', + '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');" + ) + + # 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_visible('#{}_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_visible(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_check(cb_css) + assert world.css_find(cb_css).checked + + +@step(u'The course is added to my cart') +def see_course_is_added_to_my_cart(step): + assert False, 'This step must be implemented' +@step(u'I view the payment page') +def view_the_payment_page(step): + assert False, 'This step must be implemented' +@step(u'I have submitted photos to verify my identity') +def submitted_photos_to_verify_my_identity(step): + assert False, 'This step must be implemented' +@step(u'I submit valid payment information') +def submit_valid_payment_information(step): + assert False, 'This step must be implemented' +@step(u'I see that my payment was successful') +def sesee_that_my_payment_was_successful(step): + assert False, 'This step must be implemented' +@step(u'I receive an email confirmation') +def receive_an_email_confirmation(step): + assert False, 'This step must be implemented' +@step(u'I see that I am registered for a verified certificate course on my dashboard') +def see_that_i_am_registered_for_a_verified_certificate_course_on_my_dashboard(step): + assert False, 'This step must be implemented' +@step(u'I have submitted my "([^"]*)" photo') +def submitted_my_group1_photo(step, group1): + assert False, 'This step must be implemented' +@step(u'I retake my "([^"]*)" photo') +def retake_my_group1_photo(step, group1): + assert False, 'This step must be implemented' +@step(u'I see the new photo on the confirmation page.') +def sesee_the_new_photo_on_the_confirmation_page(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 leave the flow and return') +def leave_the_flow_and_return(step): + assert False, 'This step must be implemented' +@step(u'I see the payment page') +def see_the_payment_page(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' From e0372b00ef1d10c4c950d85deafec1747f403ab6 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 28 Aug 2013 16:43:33 -0400 Subject: [PATCH 128/185] Implemented fake payment page. --- .../shoppingcart/tests/payment_fake.py | 229 ++++++++++++++++++ .../shoppingcart/tests/test_payment_fake.py | 112 +++++++++ lms/djangoapps/shoppingcart/urls.py | 7 + lms/envs/acceptance.py | 18 ++ lms/envs/test.py | 20 ++ .../shoppingcart/test/fake_payment_error.html | 9 + .../shoppingcart/test/fake_payment_page.html | 12 + 7 files changed, 407 insertions(+) create mode 100644 lms/djangoapps/shoppingcart/tests/payment_fake.py create mode 100644 lms/djangoapps/shoppingcart/tests/test_payment_fake.py create mode 100644 lms/templates/shoppingcart/test/fake_payment_error.html create mode 100644 lms/templates/shoppingcart/test/fake_payment_page.html diff --git a/lms/djangoapps/shoppingcart/tests/payment_fake.py b/lms/djangoapps/shoppingcart/tests/payment_fake.py new file mode 100644 index 0000000000..ab0d16fa51 --- /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, '/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_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/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/envs/acceptance.py b/lms/envs/acceptance.py index cf64404161..34112566ee 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(): @@ -83,6 +84,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 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/templates/shoppingcart/test/fake_payment_error.html b/lms/templates/shoppingcart/test/fake_payment_error.html new file mode 100644 index 0000000000..fcfe21ed15 --- /dev/null +++ b/lms/templates/shoppingcart/test/fake_payment_error.html @@ -0,0 +1,9 @@ + + + Payment Error + + +

        An error occurred while you submitted your order. + If you are trying to make a purchase, please contact the merchant.

        + + diff --git a/lms/templates/shoppingcart/test/fake_payment_page.html b/lms/templates/shoppingcart/test/fake_payment_page.html new file mode 100644 index 0000000000..ba488bbdb4 --- /dev/null +++ b/lms/templates/shoppingcart/test/fake_payment_page.html @@ -0,0 +1,12 @@ + +Payment Form + +

        Payment page

        +
        + % for name, value in post_params.items(): + + % endfor + +
        + + From 7bc997d0fb95b2847672e014014e1ac6f784b402 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 29 Aug 2013 17:41:54 -0400 Subject: [PATCH 129/185] Change CSS after rebase. --- lms/djangoapps/courseware/features/certificates.py | 8 ++++---- lms/djangoapps/shoppingcart/tests/payment_fake.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/features/certificates.py b/lms/djangoapps/courseware/features/certificates.py index a18e01a92e..537679008e 100644 --- a/lms/djangoapps/courseware/features/certificates.py +++ b/lms/djangoapps/courseware/features/certificates.py @@ -65,7 +65,7 @@ def select_the_verified_track(step): select_contribution(32) btn_css = 'input[value="Select Certificate"]' world.css_click(btn_css) - assert world.is_css_present('li.current#progress-step0') + assert world.is_css_present('section.progress') @step(u'I should see the course on my dashboard$') @@ -77,9 +77,9 @@ def should_see_the_course_on_my_dashboard(step): @step(u'I go to step "([^"]*)"$') def goto_next_step(step, step_num): btn_css = { - '1': 'p.m-btn-primary a', - '2': '#face_next_button a.next', - '3': '#photo_id_next_button a.next', + '1': '#face_next_button', + '2': '#face_next_button', + '3': '#photo_id_next_button', '4': '#pay_button', } next_css = { diff --git a/lms/djangoapps/shoppingcart/tests/payment_fake.py b/lms/djangoapps/shoppingcart/tests/payment_fake.py index ab0d16fa51..fa6f401904 100644 --- a/lms/djangoapps/shoppingcart/tests/payment_fake.py +++ b/lms/djangoapps/shoppingcart/tests/payment_fake.py @@ -63,7 +63,7 @@ class PaymentFakeView(View): served by the shopping cart app. """ if self._is_signature_valid(request.POST): - return self._payment_page_response(request.POST, '/postpay_callback') + return self._payment_page_response(request.POST, '/shoppingcart/postpay_callback/') else: return render_to_response('shoppingcart/test/fake_payment_error.html') From b9ccc37c5716997ccdf7a26dfbc79956d58249e4 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Fri, 30 Aug 2013 12:15:25 -0400 Subject: [PATCH 130/185] Automate more scenarios --- .../courseware/features/certificates.feature | 12 ++- .../courseware/features/certificates.py | 75 +++++++++++++------ 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/lms/djangoapps/courseware/features/certificates.feature b/lms/djangoapps/courseware/features/certificates.feature index 988968e938..3a041d92b0 100644 --- a/lms/djangoapps/courseware/features/certificates.feature +++ b/lms/djangoapps/courseware/features/certificates.feature @@ -21,15 +21,19 @@ Feature: Verified certificates And I select a contribution amount And I confirm that the details match And I go to step "4" - Then The course is added to my cart - And I view the payment page + 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 - And I receive an email confirmation - And I see that I am registered for a verified certificate course on my dashboard + + 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 Scenario: I can re-take photos Given I have submitted my "" photo diff --git a/lms/djangoapps/courseware/features/certificates.py b/lms/djangoapps/courseware/features/certificates.py index 537679008e..d221a56f71 100644 --- a/lms/djangoapps/courseware/features/certificates.py +++ b/lms/djangoapps/courseware/features/certificates.py @@ -5,6 +5,7 @@ from lettuce import world, step from lettuce.django import django_url from course_modes.models import CourseMode from selenium.common.exceptions import WebDriverException +from nose.tools import assert_equal def create_cert_course(): world.clear_courses() @@ -105,7 +106,7 @@ def capture_my_photo(step, name): "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');" + "image[0].src = canvas[0].toDataURL('image/png').replace('image/png', 'image/octet-stream');" ) # Mirror the javascript of the photo_verification.html page @@ -113,7 +114,7 @@ def capture_my_photo(step, name): 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_visible('#{}_approve_button'.format(name)) + assert world.css_find('#{}_approve_button'.format(name)) @step(u'I approve my "([^"]*)" photo$') @@ -129,7 +130,7 @@ def approve_my_photo(step, name): # Make sure that the carousel is in the right place assert world.css_has_class(wrapper_css[name], 'carousel-active') - assert world.css_visible(button_css[name]) + 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. @@ -158,29 +159,59 @@ def confirm_details_match(step): assert world.css_find(cb_css).checked -@step(u'The course is added to my cart') -def see_course_is_added_to_my_cart(step): - assert False, 'This step must be implemented' -@step(u'I view the payment page') -def view_the_payment_page(step): - assert False, 'This step must be implemented' +@step(u'I am at the payment page') +def at_the_payment_page(step): + world.css_find('input') + assert_equal(world.browser.title, u'Payment Form') + + +@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): - assert False, 'This step must be implemented' -@step(u'I submit valid payment information') -def submit_valid_payment_information(step): - assert False, 'This step must be implemented' + 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 sesee_that_my_payment_was_successful(step): - assert False, 'This step must be implemented' -@step(u'I receive an email confirmation') -def receive_an_email_confirmation(step): - assert False, 'This step must be implemented' -@step(u'I see that I am registered for a verified certificate course on my dashboard') -def see_that_i_am_registered_for_a_verified_certificate_course_on_my_dashboard(step): - assert False, 'This step must be implemented' +def see_that_my_payment_was_successful(step): + world.css_find('div') + assert_equal(world.browser.title, u'Receipt for Order 1') + + +@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 have submitted my "([^"]*)" photo') -def submitted_my_group1_photo(step, group1): +def submitted_my_foo_photo(step, name): assert False, 'This step must be implemented' @step(u'I retake my "([^"]*)" photo') def retake_my_group1_photo(step, group1): From 98f80c36c1b1a5c87bc463b94afb0ec6e1d27e73 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 3 Sep 2013 10:57:49 -0400 Subject: [PATCH 131/185] Fix Javascript bugs that arose during refactoring. --- lms/static/js/verify_student/photocapture.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lms/static/js/verify_student/photocapture.js b/lms/static/js/verify_student/photocapture.js index 1c609be5f3..01f50cc36e 100644 --- a/lms/static/js/verify_student/photocapture.js +++ b/lms/static/js/verify_student/photocapture.js @@ -22,10 +22,11 @@ var submitToPaymentProcessing = function() { { contribution = contribution_input.val(); } + var course_id = $("input[name='course_id']").val(); var xhr = $.post( - "create_order", + "/verify_student/create_order", { - "course_id" : "${course_id}", + "course_id" : course_id, "contribution": contribution }, function(data) { From d1ec21ed1cb3ee3f1862e66e1a84494c5f895918 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 3 Sep 2013 10:57:49 -0400 Subject: [PATCH 132/185] Fix Javascript bugs that arose during refactoring. --- lms/static/js/verify_student/photocapture.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lms/static/js/verify_student/photocapture.js b/lms/static/js/verify_student/photocapture.js index 1c609be5f3..01f50cc36e 100644 --- a/lms/static/js/verify_student/photocapture.js +++ b/lms/static/js/verify_student/photocapture.js @@ -22,10 +22,11 @@ var submitToPaymentProcessing = function() { { contribution = contribution_input.val(); } + var course_id = $("input[name='course_id']").val(); var xhr = $.post( - "create_order", + "/verify_student/create_order", { - "course_id" : "${course_id}", + "course_id" : course_id, "contribution": contribution }, function(data) { From 47a9f8fc5f42dc063627791e6fc07a195783db05 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 3 Sep 2013 12:55:13 -0400 Subject: [PATCH 133/185] Verification: added in supporting disabled/not ready styling for wizard nav + expandable height fix --- lms/static/sass/elements/_controls.scss | 10 ++++++++++ lms/static/sass/views/_verification.scss | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lms/static/sass/elements/_controls.scss b/lms/static/sass/elements/_controls.scss index 59ac268f8e..e7d884d146 100644 --- a/lms/static/sass/elements/_controls.scss +++ b/lms/static/sass/elements/_controls.scss @@ -168,6 +168,16 @@ } } +// 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 diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index d1e22e6d57..1b1894459f 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -162,7 +162,7 @@ .expandable-area { visibility: visible; - height: ($baseline*10.5); + height: ($baseline*12); opacity: 1.0; } } @@ -957,6 +957,10 @@ &.is-not-ready { background: $m-gray-l4; + + .action-primary { + @extend .btn-primary-disabled; + } } } From e0110bf1081f68d5726b64362ff36becb85c5e1b Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 3 Sep 2013 13:51:47 -0400 Subject: [PATCH 134/185] Change css for payment page for better synchronization. --- lms/djangoapps/courseware/features/certificates.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/features/certificates.py b/lms/djangoapps/courseware/features/certificates.py index d221a56f71..c63be69175 100644 --- a/lms/djangoapps/courseware/features/certificates.py +++ b/lms/djangoapps/courseware/features/certificates.py @@ -4,7 +4,6 @@ from lettuce import world, step from lettuce.django import django_url from course_modes.models import CourseMode -from selenium.common.exceptions import WebDriverException from nose.tools import assert_equal def create_cert_course(): @@ -161,8 +160,7 @@ def confirm_details_match(step): @step(u'I am at the payment page') def at_the_payment_page(step): - world.css_find('input') - assert_equal(world.browser.title, u'Payment Form') + assert world.css_find('input[name=transactionSignature]') @step(u'I submit valid payment information$') From 0c3af967925a9ef6e78cab5963ce114dd86420d6 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 3 Sep 2013 14:35:16 -0400 Subject: [PATCH 135/185] Add the javascript to control payment button back in. --- lms/static/js/verify_student/photocapture.js | 17 +++++++---------- .../verify_student/photo_verification.html | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lms/static/js/verify_student/photocapture.js b/lms/static/js/verify_student/photocapture.js index 01f50cc36e..4bb52ef87a 100644 --- a/lms/static/js/verify_student/photocapture.js +++ b/lms/static/js/verify_student/photocapture.js @@ -170,16 +170,13 @@ function objectTagForFlashCamera(name) { $(document).ready(function() { $(".carousel-nav").addClass('sr'); $("#pay_button").click(submitToPaymentProcessing); - // $("#confirm_pics_good").click(function() { - // if (this.checked) { - // $("#pay_button_frame").removeClass('disabled'); - // } - // else { - // $("#pay_button_frame").addClass('disabled'); - // } - // }); - // - // $("#pay_button_frame").addClass('disabled'); + // prevent browsers from keeping this button checked + $("#confirm_pics_good").prop("checked", false) + $("#confirm_pics_good").change(function() { + $("#pay_button").toggleClass('disabled'); + }); + + $("#pay_button_frame").addClass('disabled'); var hasHtml5CameraSupport = initVideoCapture(); diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index d99a16e099..6fe5f9c4a4 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -352,7 +352,7 @@
        - +
        From 4b3918c7e5f8d3f02fc028d63110823424b37b17 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 3 Sep 2013 16:30:42 -0400 Subject: [PATCH 136/185] Verification: adds styling for already verified view and refactors html/styling for shared elements --- lms/static/sass/views/_verification.scss | 55 ++++++++- .../verify_student/photo_verification.html | 6 +- .../verify_student/show_requirements.html | 2 +- lms/templates/verify_student/verified.html | 109 ++++++++++++++++-- 4 files changed, 156 insertions(+), 16 deletions(-) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 1b1894459f..e58d538310 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -503,7 +503,7 @@ } // confirmation step w/ icon - &#progress-step5 { + &.progress-step-icon { .step-number { margin-top: ($baseline/2); @@ -1585,7 +1585,7 @@ } } - // VIEW: take and review photos + // VIEW: confirmation/receipt &.step-confirmation { // progress nav @@ -1625,3 +1625,54 @@ } } } + +// 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 $m-green-l2; + + .wrapper-step-number { + border-color: $m-green-l2; + } + + .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: 47%; + } + + .progress-sts-value { + width: 32% !important; + } +} diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 6fe5f9c4a4..845171b52d 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -61,7 +61,7 @@ ${_("Make Payment")} -
      • +
      • @@ -94,8 +94,6 @@

      • -
        @@ -180,8 +178,6 @@

        - diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html index 26570a4072..49624194df 100644 --- a/lms/templates/verify_student/show_requirements.html +++ b/lms/templates/verify_student/show_requirements.html @@ -66,7 +66,7 @@ ${_("Make Payment")} -
      • +
      • diff --git a/lms/templates/verify_student/verified.html b/lms/templates/verify_student/verified.html index 0fece909ab..cb51fdd69a 100644 --- a/lms/templates/verify_student/verified.html +++ b/lms/templates/verify_student/verified.html @@ -2,6 +2,10 @@ <%! 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 [Course Name] | Verification")} + <%block name="js_extra"> -<%block name="content"> -

        You have already been verified. Hooray!

        -

        You have decided to pay $${chosen_price}

        -
        - - - -
        +<%block name="content"> +
        +
        + + + +
        +
        +

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

        +
        + + +
        +
        + +
        + +
        +
        +
        From dc850370b2c2e596e7d47f65c68fdacacb08b42b Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 3 Sep 2013 16:33:25 -0400 Subject: [PATCH 137/185] Verification: resolves layout issue with progress nav in diff screen widths --- lms/static/sass/views/_verification.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index e58d538310..4601711e6a 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -472,6 +472,7 @@ @extend .ui-depth2; position: relative; width: flex-grid(2,12); + height: ($baseline*6); float: left; padding: $baseline $baseline ($baseline*1.5) $baseline; text-align: center; From 70ba52b4fab93f40ca89fd468bd282088d061651 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 3 Sep 2013 16:37:34 -0400 Subject: [PATCH 138/185] Verification: syncing all submit button-type styling (including newly used element on verified view) --- lms/static/sass/views/_verification.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 4601711e6a..a1c663d90f 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -222,9 +222,9 @@ } // HACK: nasty override due to our bad input/button styling - button, input[type="submit"], input[type="button"] { + button, input[type="submit"], input[type="button"], button[type="submit"] { @include font-size(16); - @extend .t-weight3; + @extend .t-weight4; text-transform: none; text-shadow: none; letter-spacing: 0; From a432cffc2ca9303bec685a9402241249fb6d2616 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 3 Sep 2013 16:41:34 -0400 Subject: [PATCH 139/185] Mark tests for unimplemented features as skip so that the acceptance tests will all pass when we merge into master. --- .../courseware/features/certificates.feature | 29 +++++++++++++++---- .../courseware/features/certificates.py | 26 ++++++++--------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/lms/djangoapps/courseware/features/certificates.feature b/lms/djangoapps/courseware/features/certificates.feature index 3a041d92b0..103936b6e6 100644 --- a/lms/djangoapps/courseware/features/certificates.feature +++ b/lms/djangoapps/courseware/features/certificates.feature @@ -28,6 +28,9 @@ Feature: Verified certificates When I submit valid payment information Then I see that my payment was successful + + # Not yet implemented LMS-982 + @skip Scenario: Verified courses display correctly on dashboard Given I have submitted photos to verify my identity When I submit valid payment information @@ -35,6 +38,8 @@ Feature: Verified certificates 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 @@ -45,19 +50,31 @@ Feature: Verified certificates | 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 + Given I have submitted photos to verify my identity When I leave the flow and return - I see the payment page + 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 - #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 + @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 index c63be69175..b1f31d2f70 100644 --- a/lms/djangoapps/courseware/features/certificates.py +++ b/lms/djangoapps/courseware/features/certificates.py @@ -208,14 +208,18 @@ def see_that_i_am_on_the_verified_track(step): assert False, 'Implement this step after the design is done' -@step(u'I have submitted my "([^"]*)" photo') -def submitted_my_foo_photo(step, name): - assert False, 'This step must be implemented' -@step(u'I retake my "([^"]*)" photo') -def retake_my_group1_photo(step, group1): - assert False, 'This step must be implemented' -@step(u'I see the new photo on the confirmation page.') -def sesee_the_new_photo_on_the_confirmation_page(step): +@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): @@ -229,12 +233,6 @@ def sesee_the_new_name_on_the_confirmation_page(step): @step(u'I have submitted photos') def submitted_photos(step): assert False, 'This step must be implemented' -@step(u'I leave the flow and return') -def leave_the_flow_and_return(step): - assert False, 'This step must be implemented' -@step(u'I see the payment page') -def see_the_payment_page(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' From b75398b0df518236d2164875deb4af7229c74763 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 3 Sep 2013 17:17:03 -0400 Subject: [PATCH 140/185] Verification: moves common page elements/copy into central templates w/ supportive styling --- .../templates/course_modes/_contribution.html | 60 +++++++++---------- common/templates/course_modes/choose.html | 42 +++---------- lms/static/sass/views/_verification.scss | 5 ++ .../verify_student/_modal_editname.html | 27 +++++++++ .../verify_student/_verification_header.html | 15 +++++ .../verify_student/_verification_support.html | 21 +++++++ .../verify_student/photo_verification.html | 60 +------------------ .../verify_student/show_requirements.html | 34 +---------- lms/templates/verify_student/verified.html | 34 +---------- 9 files changed, 112 insertions(+), 186 deletions(-) create mode 100644 lms/templates/verify_student/_modal_editname.html create mode 100644 lms/templates/verify_student/_verification_header.html create mode 100644 lms/templates/verify_student/_verification_support.html diff --git a/common/templates/course_modes/_contribution.html b/common/templates/course_modes/_contribution.html index f63e3b2e4d..0294a842b2 100644 --- a/common/templates/course_modes/_contribution.html +++ b/common/templates/course_modes/_contribution.html @@ -1,32 +1,32 @@ -
          - % for price in suggested_prices: -
        • - - -
        • - % endfor +
            + % for price in suggested_prices: +
          • + + +
          • + % endfor -
          • -
              -
            • - - -
            • +
            • +
                +
              • + + +
              • -
              • - -
                - $ - - ${currency} -
                -
              • -
              -
            • -
            +
          • + +
            + $ + + ${currency} +
            +
          • +
          + +
        diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index 3188f250b0..6198a9ff4f 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -37,14 +37,7 @@ $(document).ready(function() {
        - + <%include file="/verify_student/_verification_header.html" />
        @@ -153,10 +146,14 @@ $(document).ready(function() {

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

        + +

        ${_("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 @@ -165,32 +162,7 @@ $(document).ready(function() {
        -
        - -
        + <%include file="/verify_student/_verification_support.html" />
        diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index a1c663d90f..65f5eaea7f 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -1080,6 +1080,10 @@ // VIEW: select a track &.step-select-track { + .sts-track { + @extend .text-sr; + } + .form-register-choose { @include clearfix(); width: flex-grid(12,12); @@ -1214,6 +1218,7 @@ .title { @extend .hd-lv4; @extend .t-weight4; + margin-top: $baseline; margin-bottom: ($baseline/2); } diff --git a/lms/templates/verify_student/_modal_editname.html b/lms/templates/verify_student/_modal_editname.html new file mode 100644 index 0000000000..3a45573c23 --- /dev/null +++ b/lms/templates/verify_student/_modal_editname.html @@ -0,0 +1,27 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/lms/templates/verify_student/_verification_header.html b/lms/templates/verify_student/_verification_header.html new file mode 100644 index 0000000000..5385555b2a --- /dev/null +++ b/lms/templates/verify_student/_verification_header.html @@ -0,0 +1,15 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/lms/templates/verify_student/_verification_support.html b/lms/templates/verify_student/_verification_support.html new file mode 100644 index 0000000000..d955f35a0b --- /dev/null +++ b/lms/templates/verify_student/_verification_support.html @@ -0,0 +1,21 @@ +<%! from django.utils.translation import ugettext as _ %> + +
        + +
        diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 845171b52d..b05d39a5a5 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -16,19 +16,7 @@
        - + <%include file="_verification_header.html" />
        @@ -359,51 +347,9 @@
    -
    - -
    + <%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 index 49624194df..49aa443ebe 100644 --- a/lms/templates/verify_student/show_requirements.html +++ b/lms/templates/verify_student/show_requirements.html @@ -22,19 +22,7 @@
    - + <%include file="_verification_header.html" />
    @@ -162,25 +150,7 @@
    -
    - -
    + <%include file="_verification_support.html" />
    diff --git a/lms/templates/verify_student/verified.html b/lms/templates/verify_student/verified.html index cb51fdd69a..340e8186a0 100644 --- a/lms/templates/verify_student/verified.html +++ b/lms/templates/verify_student/verified.html @@ -40,19 +40,7 @@ $(document).ready(function() {
    - + <%include file="_verification_header.html" />
    @@ -112,25 +100,7 @@ $(document).ready(function() {
    -
    - -
    + <%include file="_verification_support.html" />
    From 35a7b75eb2f80f4f7765df2bac1de380c8c4fcd6 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 3 Sep 2013 18:03:02 -0400 Subject: [PATCH 141/185] Fix up some links to point to the right place. --- lms/templates/verify_student/_verification_support.html | 4 ++-- lms/templates/verify_student/photo_verification.html | 2 +- lms/templates/verify_student/show_requirements.html | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lms/templates/verify_student/_verification_support.html b/lms/templates/verify_student/_verification_support.html index d955f35a0b..e2f598674f 100644 --- a/lms/templates/verify_student/_verification_support.html +++ b/lms/templates/verify_student/_verification_support.html @@ -6,14 +6,14 @@
  20. ${_("Have questions?")}

    -

    ${_("Please read {a_start} our FAQs to view common questions about our certificates {a_end}.").format(a_start='', a_end="")}

    +

    ${_("Please read {a_start}our FAQs to view common questions about our certificates{a_end}.").format(a_start='', a_end="")}

  21. ${_("Change your mind?")}

    -

    ${_("You can always {a_start} Audit the course for free {a_end} without verifying.").format(a_start='', a_end="")}

    +

    ${_("You can always {a_start} audit the course for free {a_end} without verifying.").format(a_start='', a_end="")}

  22. diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index b05d39a5a5..b5d1381ecd 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -305,7 +305,7 @@ diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html index 49aa443ebe..c358bb0e0a 100644 --- a/lms/templates/verify_student/show_requirements.html +++ b/lms/templates/verify_student/show_requirements.html @@ -139,7 +139,7 @@