From 988a7a1fba6fdc1a9c8b39900842cd2d691584e7 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 7 Aug 2013 15:48:40 -0700 Subject: [PATCH 01/36] 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 4d81383e0a1e19728b85a0f48ca0d5ff9ad15688 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 7 Aug 2013 22:53:36 -0700 Subject: [PATCH 02/36] 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 ea7cf3a24eeeebc455b92154d155eeb139e437f6 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 7 Aug 2013 23:29:33 -0700 Subject: [PATCH 03/36] 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 3f9c52cd1cb097112f6a3fc1c2b484a59ea1560c Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 8 Aug 2013 18:07:18 -0700 Subject: [PATCH 04/36] 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 ff5ca76aa67e645f7c5e4dece4aa85d7fdbddc73 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 8 Aug 2013 21:55:08 -0700 Subject: [PATCH 05/36] 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 44be024168768793b24b0a4d7b073f0d442d841a Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 8 Aug 2013 23:01:29 -0700 Subject: [PATCH 06/36] 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 41b9f9f071d023871fa7da1b1c0297f3fb1d63fe Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Fri, 9 Aug 2013 11:01:32 -0700 Subject: [PATCH 07/36] 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 e4e22f0f85c1edc9ae09b04602bc5de5481b2dde Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Fri, 9 Aug 2013 18:36:35 -0700 Subject: [PATCH 08/36] 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 5ae2289df09d2d0ee2bf999cb7b93d1d8b23cf14 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 13 Aug 2013 11:41:05 -0700 Subject: [PATCH 09/36] about page changes, refactor processor reply handling --- 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 +- 10 files changed, 159 insertions(+), 138 deletions(-) delete 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 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 461b4da349c0d67bf5754bf318f1dd0411bbe21f Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 9 Aug 2013 12:57:34 -0400 Subject: [PATCH 10/36] 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 23a15aed57367192510da7e93e4d29b320ba9308 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 12 Aug 2013 12:18:26 -0400 Subject: [PATCH 11/36] 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 10c96cb897152c83fd348ea1bf6d33e82c540d2d Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 10:12:06 -0400 Subject: [PATCH 12/36] Remove enrollment_cost from course_module --- lms/djangoapps/shoppingcart/models.py | 4 ---- 1 file changed, 4 deletions(-) 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 4070984cac27530e964a60bd4e03f352e084bf00 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 13:36:13 -0400 Subject: [PATCH 13/36] 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 5a90a6590f0781694134edb5b96dfae97dded5d1 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 14:18:46 -0400 Subject: [PATCH 14/36] 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 84628e105ff4c53fdd23dd72abbf6b55405894aa Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 15:22:17 -0400 Subject: [PATCH 15/36] 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 6f3e83b86cd93f1df91c2c7cffbd7b010bc151ab Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 20 Aug 2013 15:28:19 -0400 Subject: [PATCH 16/36] 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 5fbb12cb61ad4eec67c3d767b62553a1c58c39de Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 13:20:06 -0700 Subject: [PATCH 17/36] 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 6f70c9b9ce97682e30adb29a8e7e52d083aa99e1 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 14:49:26 -0700 Subject: [PATCH 18/36] 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 ca3651fa355d6ee4b0556e0453da620e9aa0bc77 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 18:17:06 -0700 Subject: [PATCH 19/36] 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 9fdf60ff454adb8aa761173c249e88861dbcc078 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 18:55:59 -0700 Subject: [PATCH 20/36] 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 d140ffd868c2b0cf4401c191f5c5f05e1635ef77 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 21:36:32 -0700 Subject: [PATCH 21/36] 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 055ad5357d3300be16c6c279e57996045156a791 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 23:58:27 -0700 Subject: [PATCH 22/36] 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 b475ac36f1e5a8d4ead7193da5e8b65e0632e3e4 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 09:31:32 -0400 Subject: [PATCH 23/36] 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 9798d020d101b760751b602399410d1e07101a56 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 11:10:47 -0400 Subject: [PATCH 24/36] 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 1bff390ba893afd3d93214d534af9df05c3d44b0 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 09:58:13 -0700 Subject: [PATCH 25/36] 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 ee10cf7e96bea8b3c10e0dce7c25d34a91282d0d Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 10:06:14 -0700 Subject: [PATCH 26/36] 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 e30ebf504171f1fab34f620f9d00fb8d5a91dcac Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 10:31:18 -0700 Subject: [PATCH 27/36] 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 6c19f3a7adf7fe430be98932577806eba9308bbf Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 13:53:58 -0400 Subject: [PATCH 28/36] 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 3a0a56f3a98b9834718db814428d5eb50f5ff83c Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 14:49:18 -0400 Subject: [PATCH 29/36] 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 dee127360b7670da2cbc7691416d5af71e5e3a55 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 15:18:21 -0400 Subject: [PATCH 30/36] 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 e8db9e8f2a78b82a2e2e615e17e0096a6967cf4e Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 16:40:42 -0400 Subject: [PATCH 31/36] 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 2c4b1e17b45a28103a1c0f6a0d2901b940123948 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 13:30:52 -0700 Subject: [PATCH 32/36] 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 1202b77d7dd7cd93e66290e204be190a37c0713a Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 18:56:03 -0700 Subject: [PATCH 33/36] 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 d7e311f5d2ff65e5216f92115c6ae3162edd8cb8 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 19:17:31 -0700 Subject: [PATCH 34/36] 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 569727e60898e585b62a2cf972914ac32924e9b5 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 22 Aug 2013 10:34:16 -0400 Subject: [PATCH 35/36] Pep8 fixes --- common/lib/xmodule/xmodule/course_module.py | 310 +++++++++--------- .../shoppingcart/tests/test_models.py | 15 +- .../shoppingcart/tests/test_views.py | 9 +- lms/djangoapps/shoppingcart/views.py | 2 +- 4 files changed, 173 insertions(+), 163 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 4555395fef..10bcf4a4e3 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -147,51 +147,51 @@ class TextbookList(List): class CourseFields(object): textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", - default=[], scope=Scope.content) + default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings) enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) start = Date(help="Start time when this module is visible", - # using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the - # time of first invocation of this stmt on the server - default=datetime.fromtimestamp(0, UTC()), - scope=Scope.settings) + # using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the + # time of first invocation of this stmt on the server + default=datetime.fromtimestamp(0, UTC()), + scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings) grading_policy = Dict(help="Grading policy definition for this class", - default={"GRADER": [ - { - "type": "Homework", - "min_count": 12, - "drop_count": 2, - "short_label": "HW", - "weight": 0.15 - }, - { - "type": "Lab", - "min_count": 12, - "drop_count": 2, - "weight": 0.15 - }, - { - "type": "Midterm Exam", - "short_label": "Midterm", - "min_count": 1, - "drop_count": 0, - "weight": 0.3 - }, - { - "type": "Final Exam", - "short_label": "Final", - "min_count": 1, - "drop_count": 0, - "weight": 0.4 - } - ], - "GRADE_CUTOFFS": { - "Pass": 0.5 - }}, - scope=Scope.content) + default={"GRADER": [ + { + "type": "Homework", + "min_count": 12, + "drop_count": 2, + "short_label": "HW", + "weight": 0.15 + }, + { + "type": "Lab", + "min_count": 12, + "drop_count": 2, + "weight": 0.15 + }, + { + "type": "Midterm Exam", + "short_label": "Midterm", + "min_count": 1, + "drop_count": 0, + "weight": 0.3 + }, + { + "type": "Final Exam", + "short_label": "Final", + "min_count": 1, + "drop_count": 0, + "weight": 0.4 + } + ], + "GRADE_CUTOFFS": { + "Pass": 0.5 + }}, + scope=Scope.content) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) display_name = String(help="Display name for this module", default="Empty", display_name="Display Name", scope=Scope.settings) show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings) @@ -201,7 +201,7 @@ class CourseFields(object): discussion_topics = Dict( help="Map of topics names to ids", scope=Scope.settings - ) + ) testcenter_info = Dict(help="Dictionary of Test Center info", scope=Scope.settings) announcement = Date(help="Date this course is announced", scope=Scope.settings) cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings) @@ -216,128 +216,124 @@ class CourseFields(object): advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings) has_children = True checklists = List(scope=Scope.settings, - default=[ - {"short_description" : "Getting Started With Studio", - "items" : [{"short_description": "Add Course Team Members", - "long_description": "Grant your collaborators permission to edit your course so you can work together.", - "is_checked": False, - "action_url": "ManageUsers", - "action_text": "Edit Course Team", - "action_external": False}, - {"short_description": "Set Important Dates for Your Course", - "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Details & Schedule", - "action_external": False}, - {"short_description": "Draft Your Course's Grading Policy", - "long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.", - "is_checked": False, - "action_url": "SettingsGrading", - "action_text": "Edit Grading Settings", - "action_external": False}, - {"short_description": "Explore the Other Studio Checklists", - "long_description": "Discover other available course authoring tools, and find help when you need it.", - "is_checked": False, - "action_url": "", - "action_text": "", - "action_external": False}] - }, - {"short_description" : "Draft a Rough Course Outline", - "items" : [{"short_description": "Create Your First Section and Subsection", - "long_description": "Use your course outline to build your first Section and Subsection.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Set Section Release Dates", - "long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Designate a Subsection as Graded", - "long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Reordering Course Content", - "long_description": "Use drag and drop to reorder the content in your course.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Renaming Sections", - "long_description": "Rename Sections by clicking the Section name from the Course Outline.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Deleting Course Content", - "long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Add an Instructor-Only Section to Your Outline", - "long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}] - }, - {"short_description" : "Explore edX's Support Tools", - "items" : [{"short_description": "Explore the Studio Help Forum", - "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.", - "is_checked": False, - "action_url": "http://help.edge.edx.org/", - "action_text": "Visit Studio Help", - "action_external": True}, - {"short_description": "Enroll in edX 101", - "long_description": "Register for edX 101, edX's primer for course creation.", - "is_checked": False, - "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about", - "action_text": "Register for edX 101", - "action_external": True}, - {"short_description": "Download the Studio Documentation", - "long_description": "Download the searchable Studio reference documentation in PDF form.", - "is_checked": False, - "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf", - "action_text": "Download Documentation", - "action_external": True}] - }, - {"short_description" : "Draft Your Course About Page", - "items" : [{"short_description": "Draft a Course Description", - "long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}, - {"short_description": "Add Staff Bios", - "long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}, - {"short_description": "Add Course FAQs", - "long_description": "Include a short list of frequently asked questions about your course.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}, - {"short_description": "Add Course Prerequisites", - "long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}] - } + default=[ + {"short_description": "Getting Started With Studio", + "items": [{"short_description": "Add Course Team Members", + "long_description": "Grant your collaborators permission to edit your course so you can work together.", + "is_checked": False, + "action_url": "ManageUsers", + "action_text": "Edit Course Team", + "action_external": False}, + {"short_description": "Set Important Dates for Your Course", + "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Details & Schedule", + "action_external": False}, + {"short_description": "Draft Your Course's Grading Policy", + "long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.", + "is_checked": False, + "action_url": "SettingsGrading", + "action_text": "Edit Grading Settings", + "action_external": False}, + {"short_description": "Explore the Other Studio Checklists", + "long_description": "Discover other available course authoring tools, and find help when you need it.", + "is_checked": False, + "action_url": "", + "action_text": "", + "action_external": False}]}, + {"short_description": "Draft a Rough Course Outline", + "items": [{"short_description": "Create Your First Section and Subsection", + "long_description": "Use your course outline to build your first Section and Subsection.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Set Section Release Dates", + "long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Designate a Subsection as Graded", + "long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Reordering Course Content", + "long_description": "Use drag and drop to reorder the content in your course.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Renaming Sections", + "long_description": "Rename Sections by clicking the Section name from the Course Outline.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Deleting Course Content", + "long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Add an Instructor-Only Section to Your Outline", + "long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}]}, + {"short_description": "Explore edX's Support Tools", + "items": [{"short_description": "Explore the Studio Help Forum", + "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.", + "is_checked": False, + "action_url": "http://help.edge.edx.org/", + "action_text": "Visit Studio Help", + "action_external": True}, + {"short_description": "Enroll in edX 101", + "long_description": "Register for edX 101, edX's primer for course creation.", + "is_checked": False, + "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about", + "action_text": "Register for edX 101", + "action_external": True}, + {"short_description": "Download the Studio Documentation", + "long_description": "Download the searchable Studio reference documentation in PDF form.", + "is_checked": False, + "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf", + "action_text": "Download Documentation", + "action_external": True}]}, + {"short_description": "Draft Your Course About Page", + "items": [{"short_description": "Draft a Course Description", + "long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}, + {"short_description": "Add Staff Bios", + "long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}, + {"short_description": "Add Course FAQs", + "long_description": "Include a short list of frequently asked questions about your course.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}, + {"short_description": "Add Course Prerequisites", + "long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}]} ]) info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') 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) + scope=Scope.settings) course_image = String( help="Filename of the course image", scope=Scope.settings, 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 b761976167aa46bd6f37574abb4291b43ae99ba3 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 22 Aug 2013 11:01:09 -0400 Subject: [PATCH 36/36] 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)