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

ERROR!

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

EXCEPTION!

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

EXCEPTION!

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

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

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

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