Commit 667a1ead by Jason Bau

Stanford paid course registration

With tests, some settings changes
(all should default to not breaking anything for edx)

Added styling for shopping cart User Experience
- Styled shoppingcart list page
- Styled navigation shopping cart button
- Styled receipt page
- Styled course about page for shopping cart courses

Addressed HTML/SCSS issues

Remove offending body class and unnecessary sass changes

Addresses many review comments on stanford shopping cart

* framework for generating order instructions on receipt page
  in shoppingcart.models
* move user_cart_has_item into shoppingcart.models
* move min_course_price_for_currency into course_modes.models
* remove auto activation on purchase
* 2-space indents in templates
* etc

revert indentation on navigation.html for ease of review

pep8 pylint

move logging/error handling from shoppingcart view to model

Addressing @dave changes
parent 78829a37
......@@ -37,7 +37,7 @@ class CourseMode(models.Model):
currency = models.CharField(default="usd", max_length=8)
# turn this mode off after the given expiration date
expiration_date = models.DateField(default=None, null=True)
expiration_date = models.DateField(default=None, null=True, blank=True)
DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd')
DEFAULT_MODE_SLUG = 'honor'
......@@ -86,6 +86,15 @@ class CourseMode(models.Model):
else:
return None
@classmethod
def min_course_price_for_currency(cls, course_id, currency):
"""
Returns the minimum price of the course in the appropriate currency over all the course's modes.
If there is no mode found, will return the price of DEFAULT_MODE, which is 0
"""
modes = cls.modes_for_course(course_id)
return min(mode.min_price for mode in modes if mode.currency == currency)
def __unicode__(self):
return u"{} : {}, min={}, prices={}".format(
self.course_id, self.mode_slug, self.min_price, self.suggested_prices
......
......@@ -73,6 +73,24 @@ class CourseModeModelTest(TestCase):
self.assertEqual(mode2, CourseMode.mode_for_course(self.course_id, u'verified'))
self.assertIsNone(CourseMode.mode_for_course(self.course_id, 'DNE'))
def test_min_course_price_for_currency(self):
"""
Get the min course price for a course according to currency
"""
# no modes, should get 0
self.assertEqual(0, CourseMode.min_course_price_for_currency(self.course_id, 'usd'))
# create some modes
mode1 = Mode(u'honor', u'Honor Code Certificate', 10, '', 'usd')
mode2 = Mode(u'verified', u'Verified Certificate', 20, '', 'usd')
mode3 = Mode(u'honor', u'Honor Code Certificate', 80, '', 'cny')
set_modes = [mode1, mode2, mode3]
for mode in set_modes:
self.create_mode(mode.slug, mode.name, mode.min_price, mode.suggested_prices, mode.currency)
self.assertEqual(10, CourseMode.min_course_price_for_currency(self.course_id, 'usd'))
self.assertEqual(80, CourseMode.min_course_price_for_currency(self.course_id, 'cny'))
def test_modes_for_course_expired(self):
expired_mode, _status = self.create_mode('verified', 'Verified Certificate')
expired_mode.expiration_date = datetime.now(pytz.UTC) + timedelta(days=-1)
......
......@@ -11,20 +11,29 @@ import unittest
from django.conf import settings
from django.test import TestCase
from django.test.utils import override_settings
from django.test.client import RequestFactory
from django.contrib.auth.models import User
from django.contrib.auth.hashers import UNUSABLE_PASSWORD
from django.contrib.auth.tokens import default_token_generator
from django.utils.http import int_to_base36
from django.core.urlresolvers import reverse
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE
from mock import Mock, patch
from textwrap import dedent
from student.models import unique_id_for_user, CourseEnrollment
from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper
from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper,
change_enrollment)
from student.tests.factories import UserFactory
from student.tests.test_email import mock_render_to_string
import shoppingcart
COURSE_1 = 'edX/toy/2012_Fall'
COURSE_2 = 'edx/full/6.002_Spring_2012'
......@@ -343,3 +352,32 @@ class EnrollInCourseTest(TestCase):
# for that user/course_id combination
CourseEnrollment.enroll(user, course_id)
self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class PaidRegistrationTest(ModuleStoreTestCase):
"""
Tests for paid registration functionality (not verified student), involves shoppingcart
"""
# arbitrary constant
COURSE_SLUG = "100"
COURSE_NAME = "test_course"
COURSE_ORG = "EDX"
def setUp(self):
# Create course
self.req_factory = RequestFactory()
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.assertIsNotNone(self.course)
self.user = User.objects.create(username="jack", email="jack@fake.edx.org")
@unittest.skipUnless(settings.MITX_FEATURES.get('ENABLE_SHOPPING_CART'), "Shopping Cart not enabled in settings")
def test_change_enrollment_add_to_cart(self):
request = self.req_factory.post(reverse('change_enrollment'), {'course_id': self.course.id,
'enrollment_action': 'add_to_cart'})
request.user = self.user
response = change_enrollment(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, reverse('shoppingcart.views.show_cart'))
self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order(
shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id))
......@@ -58,6 +58,7 @@ from external_auth.models import ExternalAuthMap
import external_auth.views
from bulk_email.models import Optout
import shoppingcart
import track.views
......@@ -405,6 +406,19 @@ def change_enrollment(request):
return HttpResponse()
elif action == "add_to_cart":
# Pass the request handling to shoppingcart.views
# The view in shoppingcart.views performs error handling and logs different errors. But this elif clause
# is only used in the "auto-add after user reg/login" case, i.e. it's always wrapped in try_change_enrollment.
# This means there's no good way to display error messages to the user. So we log the errors and send
# the user to the shopping cart page always, where they can reasonably discern the status of their cart,
# whether things got added, etc
shoppingcart.views.add_course_to_cart(request, course_id)
return HttpResponse(
reverse("shoppingcart.views.show_cart")
)
elif action == "unenroll":
try:
CourseEnrollment.unenroll(user, course_id)
......
from mock import MagicMock
"""
Tests courseware views.py
"""
from mock import MagicMock, patch
import datetime
import unittest
from django.test import TestCase
from django.http import Http404
from django.test.utils import override_settings
from django.contrib.auth.models import User
from django.contrib.auth.models import User, AnonymousUser
from django.test.client import RequestFactory
from django.conf import settings
......@@ -15,12 +19,14 @@ from student.tests.factories import AdminFactory
from mitxmako.middleware import MakoMiddleware
from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.tests.factories import CourseFactory
import courseware.views as views
from xmodule.modulestore import Location
from pytz import UTC
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from course_modes.models import CourseMode
import shoppingcart
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
......@@ -78,6 +84,32 @@ class ViewsTestCase(TestCase):
chapter = 'Overview'
self.chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter)
@unittest.skipUnless(settings.MITX_FEATURES.get('ENABLE_SHOPPING_CART'), "Shopping Cart not enabled in settings")
@patch.dict(settings.MITX_FEATURES, {'ENABLE_PAID_COURSE_REGISTRATION': True})
def test_course_about_in_cart(self):
in_cart_span = '<span class="add-to-cart">'
# don't mock this course due to shopping cart existence checking
course = CourseFactory.create(org="new", number="unenrolled", display_name="course")
request = self.request_factory.get(reverse('about_course', args=[course.id]))
request.user = AnonymousUser()
response = views.course_about(request, course.id)
self.assertEqual(response.status_code, 200)
self.assertNotIn(in_cart_span, response.content)
# authenticated user with nothing in cart
request.user = self.user
response = views.course_about(request, course.id)
self.assertEqual(response.status_code, 200)
self.assertNotIn(in_cart_span, response.content)
# now add the course to the cart
cart = shoppingcart.models.Order.get_cart_for_user(self.user)
shoppingcart.models.PaidCourseRegistration.add_to_order(cart, course.id)
response = views.course_about(request, course.id)
self.assertEqual(response.status_code, 200)
self.assertIn(in_cart_span, response.content)
def test_user_groups(self):
# depreciated function
mock_user = MagicMock()
......
......@@ -36,6 +36,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
from xmodule.modulestore.search import path_to_location
from xmodule.course_module import CourseDescriptor
import shoppingcart
import comment_client
......@@ -604,10 +605,27 @@ def course_about(request, course_id):
show_courseware_link = (has_access(request.user, course, 'load') or
settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'))
# Note: this is a flow for payment for course registration, not the Verified Certificate flow.
registration_price = 0
in_cart = False
reg_then_add_to_cart_link = ""
if settings.MITX_FEATURES.get('ENABLE_PAID_COURSE_REGISTRATION'):
registration_price = CourseMode.min_course_price_for_currency(course_id,
settings.PAID_COURSE_REGISTRATION_CURRENCY[0])
if request.user.is_authenticated():
cart = shoppingcart.models.Order.get_cart_for_user(request.user)
in_cart = shoppingcart.models.PaidCourseRegistration.contained_in_order(cart, course_id)
reg_then_add_to_cart_link = "{reg_url}?course_id={course_id}&enrollment_action=add_to_cart".format(
reg_url=reverse('register_user'), course_id=course.id)
return render_to_response('courseware/course_about.html',
{'course': course,
'registered': registered,
'course_target': course_target,
'registration_price': registration_price,
'in_cart': in_cart,
'reg_then_add_to_cart_link': reg_then_add_to_cart_link,
'show_courseware_link': show_courseware_link})
......
"""
Exceptions for the shoppingcart app
"""
# (Exception Class Names are sort of self-explanatory, so skipping docstring requirement)
# pylint: disable=C0111
class PaymentException(Exception):
pass
......@@ -8,3 +14,15 @@ class PurchasedCallbackException(PaymentException):
class InvalidCartItem(PaymentException):
pass
class ItemAlreadyInCartException(InvalidCartItem):
pass
class AlreadyEnrolledInCourseException(InvalidCartItem):
pass
class CourseDoesNotExistException(InvalidCartItem):
pass
"""
Tests for the Shopping Cart Models
"""
import smtplib
from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors
from factory import DjangoModelFactory
from mock import patch
from mock import patch, MagicMock
from django.core import mail
from django.conf import settings
from django.db import DatabaseError
from django.test import TestCase
from django.test.utils import override_settings
from django.contrib.auth.models import AnonymousUser
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 shoppingcart.models import (Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration,
OrderItemSubclassPK)
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from course_modes.models import CourseMode
......@@ -39,13 +41,24 @@ class OrderTest(ModuleStoreTestCase):
cart2 = Order.get_cart_for_user(user=self.user)
self.assertEquals(cart2.orderitem_set.count(), 1)
def test_user_cart_has_items(self):
anon = AnonymousUser()
self.assertFalse(Order.user_cart_has_items(anon))
self.assertFalse(Order.user_cart_has_items(self.user))
cart = Order.get_cart_for_user(self.user)
item = OrderItem(order=cart, user=self.user)
item.save()
self.assertTrue(Order.user_cart_has_items(self.user))
def test_cart_clear(self):
cart = Order.get_cart_for_user(user=self.user)
CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor')
CertificateItem.add_to_order(cart, 'org/test/Test_Course_1', self.cost, 'honor')
self.assertEquals(cart.orderitem_set.count(), 2)
self.assertTrue(cart.has_items())
cart.clear()
self.assertEquals(cart.orderitem_set.count(), 0)
self.assertFalse(cart.has_items())
def test_add_item_to_cart_currency_match(self):
cart = Order.get_cart_for_user(user=self.user)
......@@ -111,6 +124,22 @@ class OrderTest(ModuleStoreTestCase):
cart.purchase()
self.assertEquals(len(mail.outbox), 1)
@patch('shoppingcart.models.log.error')
def test_purchase_item_email_smtp_failure(self, error_logger):
cart = Order.get_cart_for_user(user=self.user)
CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor')
with patch('shoppingcart.models.send_mail', side_effect=smtplib.SMTPException):
cart.purchase()
self.assertTrue(error_logger.called)
@patch('shoppingcart.models.log.error')
def test_purchase_item_email_boto_failure(self, error_logger):
cart = Order.get_cart_for_user(user=self.user)
CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor')
with patch('shoppingcart.models.send_mail', side_effect=BotoServerError("status", "reason")):
cart.purchase()
self.assertTrue(error_logger.called)
def purchase_with_data(self, cart):
""" purchase a cart with billing information """
CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor')
......@@ -127,8 +156,9 @@ class OrderTest(ModuleStoreTestCase):
cardtype='001',
)
@patch('shoppingcart.models.render_to_string')
@patch.dict(settings.MITX_FEATURES, {'STORE_BILLING_INFO': True})
def test_billing_info_storage_on(self):
def test_billing_info_storage_on(self, render):
cart = Order.get_cart_for_user(self.user)
self.purchase_with_data(cart)
self.assertNotEqual(cart.bill_to_first, '')
......@@ -141,9 +171,12 @@ class OrderTest(ModuleStoreTestCase):
self.assertNotEqual(cart.bill_to_city, '')
self.assertNotEqual(cart.bill_to_state, '')
self.assertNotEqual(cart.bill_to_country, '')
((_, context), _) = render.call_args
self.assertTrue(context['has_billing_info'])
@patch('shoppingcart.models.render_to_string')
@patch.dict(settings.MITX_FEATURES, {'STORE_BILLING_INFO': False})
def test_billing_info_storage_off(self):
def test_billing_info_storage_off(self, render):
cart = Order.get_cart_for_user(self.user)
self.purchase_with_data(cart)
self.assertNotEqual(cart.bill_to_first, '')
......@@ -157,13 +190,30 @@ class OrderTest(ModuleStoreTestCase):
self.assertEqual(cart.bill_to_street2, '')
self.assertEqual(cart.bill_to_ccnum, '')
self.assertEqual(cart.bill_to_cardtype, '')
((_, context), _) = render.call_args
self.assertFalse(context['has_billing_info'])
mock_gen_inst = MagicMock(return_value=(OrderItemSubclassPK(OrderItem, 1), set([])))
def test_generate_receipt_instructions_callchain(self):
"""
This tests the generate_receipt_instructions call chain (ie calling the function on the
cart also calls it on items in the cart
"""
cart = Order.get_cart_for_user(self.user)
item = OrderItem(user=self.user, order=cart)
item.save()
self.assertTrue(cart.has_items())
with patch.object(OrderItem, 'generate_receipt_instructions', self.mock_gen_inst):
cart.generate_receipt_instructions()
self.mock_gen_inst.assert_called_with()
class OrderItemTest(TestCase):
def setUp(self):
self.user = UserFactory.create()
def test_orderItem_purchased_callback(self):
def test_order_item_purchased_callback(self):
"""
This tests that calling purchased_callback on the base OrderItem class raises NotImplementedError
"""
......@@ -171,6 +221,19 @@ class OrderItemTest(TestCase):
with self.assertRaises(NotImplementedError):
item.purchased_callback()
def test_order_item_generate_receipt_instructions(self):
"""
This tests that the generate_receipt_instructions call chain and also
that calling it on the base OrderItem class returns an empty list
"""
cart = Order.get_cart_for_user(self.user)
item = OrderItem(user=self.user, order=cart)
item.save()
self.assertTrue(cart.has_items())
(inst_dict, inst_set) = cart.generate_receipt_instructions()
self.assertDictEqual({item.pk_with_subclass: set([])}, inst_dict)
self.assertEquals(set([]), inst_set)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class PaidCourseRegistrationTest(ModuleStoreTestCase):
......@@ -195,8 +258,8 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
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.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_id))
self.assertFalse(PaidCourseRegistration.contained_in_order(self.cart, self.course_id + "abcd"))
self.assertEqual(self.cart.total_cost, self.cost)
def test_add_with_default_mode(self):
......@@ -212,7 +275,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
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))
self.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_id))
def test_purchased_callback(self):
reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id)
......@@ -221,6 +284,26 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
reg1 = PaidCourseRegistration.objects.get(id=reg1.id) # reload from DB to get side-effect
self.assertEqual(reg1.status, "purchased")
def test_generate_receipt_instructions(self):
"""
Add 2 courses to the order and make sure the instruction_set only contains 1 element (no dups)
"""
course2 = CourseFactory.create(org='MITx', number='998', display_name='Robot Duper Course')
course_mode2 = CourseMode(course_id=course2.id,
mode_slug="honor",
mode_display_name="honor cert",
min_price=self.cost)
course_mode2.save()
pr1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id)
pr2 = PaidCourseRegistration.add_to_order(self.cart, course2.id)
self.cart.purchase()
inst_dict, inst_set = self.cart.generate_receipt_instructions()
self.assertEqual(2, len(inst_dict))
self.assertEqual(1, len(inst_set))
self.assertIn("dashboard", inst_set.pop())
self.assertIn(pr1.pk_with_subclass, inst_dict)
self.assertIn(pr2.pk_with_subclass, inst_dict)
def test_purchased_callback_exception(self):
reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id)
reg1.course_id = "changedforsomereason"
......
......@@ -85,7 +85,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
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))
self.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_id))
@patch('shoppingcart.views.render_purchase_form_html', form_mock)
......
......@@ -6,30 +6,33 @@ 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 Order, PaidCourseRegistration, CertificateItem, OrderItem
from .models import Order, PaidCourseRegistration, OrderItem
from .processors import process_postpay_callback, render_purchase_form_html
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException
log = logging.getLogger("shoppingcart")
@require_POST
def add_course_to_cart(request, course_id):
"""
Adds course specified by course_id to the cart. The model function add_to_order does all the
heavy lifting (logging, error checking, etc)
"""
if not request.user.is_authenticated():
log.info("Anon user trying to add course {} to cart".format(course_id))
return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart'))
cart = Order.get_cart_for_user(request.user)
if PaidCourseRegistration.part_of_order(cart, course_id):
return HttpResponseBadRequest(_('The course {0} is already in your cart.'.format(course_id)))
if CourseEnrollment.is_enrolled(user=request.user, course_id=course_id):
return HttpResponseBadRequest(_('You are already registered in course {0}.'.format(course_id)))
# All logging from here handled by the model
try:
PaidCourseRegistration.add_to_order(cart, course_id)
except ItemNotFoundError:
except CourseDoesNotExistException:
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
return HttpResponseRedirect(reverse('shoppingcart.views.show_cart'))
except ItemAlreadyInCartException:
return HttpResponseBadRequest(_('The course {0} is already in your cart.'.format(course_id)))
except AlreadyEnrolledInCourseException:
return HttpResponseBadRequest(_('You are already registered in course {0}.'.format(course_id)))
return HttpResponse(_("Course added to cart."))
......@@ -103,12 +106,14 @@ def show_receipt(request, ordernum):
order_items = OrderItem.objects.filter(order=order).select_subclasses()
any_refunds = any(i.status == "refunded" for i in order_items)
receipt_template = 'shoppingcart/receipt.html'
__, instructions = order.generate_receipt_instructions()
# we want to have the ability to override the default receipt page when
# there is only one item in the order
context = {
'order': order,
'order_items': order_items,
'any_refunds': any_refunds,
'instructions': instructions,
}
if order_items.count() == 1:
......
......@@ -100,6 +100,8 @@ with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
ENV_TOKENS = json.load(env_file)
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME)
# For displaying on the receipt. At Stanford PLATFORM_NAME != MERCHANT_NAME, but PLATFORM_NAME is a fine default
CC_MERCHANT_NAME = ENV_TOKENS.get('CC_MERCHANT_NAME', PLATFORM_NAME)
EMAIL_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND)
EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None)
EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost
......@@ -136,6 +138,8 @@ 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)
PAID_COURSE_REGISTRATION_CURRENCY = ENV_TOKENS.get('PAID_COURSE_REGISTRATION_CURRENCY',
PAID_COURSE_REGISTRATION_CURRENCY)
#Theme overrides
THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
......
......@@ -36,6 +36,7 @@ from xmodule.modulestore.inheritance import InheritanceMixin
################################### FEATURES ###################################
# The display name of the platform to be used in templates/emails/etc.
PLATFORM_NAME = "edX"
CC_MERCHANT_NAME = PLATFORM_NAME
COURSEWARE_ENABLED = True
ENABLE_JASMINE = False
......@@ -171,6 +172,9 @@ MITX_FEATURES = {
# Toggle storing detailed billing information
'STORE_BILLING_INFO': False,
# Enable flow for payments for course registration (DIFFERENT from verified student flow)
'ENABLE_PAID_COURSE_REGISTRATION': False,
}
# Used for A/B testing
......@@ -500,7 +504,8 @@ CC_PROCESSOR = {
'PURCHASE_ENDPOINT': '',
}
}
# Setting for PAID_COURSE_REGISTRATION, DOES NOT AFFECT VERIFIED STUDENTS
PAID_COURSE_REGISTRATION_CURRENCY = ['usd', '$']
################################# open ended grading config #####################
#By setting up the default settings with an incorrect user name and password,
......
......@@ -165,7 +165,6 @@ OPENID_PROVIDER_TRUSTED_ROOTS = ['*']
###################### Payment ##############################3
# Enable fake payment processing page
MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
# Configure the payment processor to use the fake processing page
# Since both the fake payment page and the shoppingcart app are using
# the same settings, we can generate this randomly and guarantee
......
......@@ -48,6 +48,7 @@
// base - specific views
@import 'views/verification';
@import 'views/shoppingcart';
// shared - course
@import 'shared/forms';
......
......@@ -98,7 +98,7 @@
@include transition(all 0.15s linear 0s);
width: flex-grid(12);
> a.find-courses, a.register {
> a.find-courses, a.register, a.add-to-cart {
@include button(shiny, $button-color);
@include box-sizing(border-box);
border-radius: 3px;
......@@ -139,7 +139,7 @@
}
}
span.register {
span.register, span.add-to-cart {
background: $button-archive-color;
border: 1px solid darken($button-archive-color, 50%);
@include box-sizing(border-box);
......
......@@ -123,6 +123,13 @@ header.global {
border-radius: 0 4px 4px 0;
border-left: none;
padding: 5px 8px 7px 8px;
&.shopping-cart {
border-radius: 4px;
border: 1px solid $border-color-2;
margin-right: 10px;
padding-bottom: 6px;
}
}
}
}
......
// lms - views - shopping cart
// ====================
.notification {
padding: 30px 30px 0 30px;
}
.cart-list {
padding: 30px;
margin-top: 40px;
border-radius: 3px;
border: 1px solid $border-color-1;
background-color: $action-primary-fg;
> h2 {
font-size: 1.5em;
color: $base-font-color;
}
.cart-table {
width: 100%;
.cart-headings {
height: 35px;
th {
text-align: left;
padding-left: 5px;
border-bottom: 1px solid $border-color-1;
&.qty {
width: 100px;
}
&.u-pr {
width: 100px;
}
&.prc {
width: 150px;
}
&.cur {
width: 100px;
}
}
}
.cart-items {
td {
padding: 10px 25px;
}
}
.cart-totals {
td {
&.cart-total-cost {
font-size: 1.25em;
font-weight: bold;
padding: 10px 25px;
}
}
}
}
table.order-receipt {
width: 100%;
.order-number {
font-weight: bold;
}
.order-date {
text-align: right;
}
.items-ordered {
padding-top: 50px;
}
tr {
}
th {
text-align: left;
padding: 25px 0 15px 0;
&.qty {
width: 50px;
}
&.u-pr {
width: 100px;
}
&.pri {
width: 125px;
}
&.curr {
width: 75px;
}
}
tr.order-item {
td {
padding-bottom: 10px;
}
}
}
}
\ No newline at end of file
......@@ -3,6 +3,8 @@
from django.core.urlresolvers import reverse
from courseware.courses import course_image_url, get_course_about_section
from courseware.access import has_access
cart_link = reverse('shoppingcart.views.show_cart')
%>
<%namespace name='static' file='../static_content.html'/>
......@@ -24,6 +26,29 @@
$("#class_enroll_form").submit();
event.preventDefault();
});
add_course_complete_handler = function(jqXHR, textStatus) {
if (jqXHR.status == 200) {
location.href = "${cart_link}";
}
if (jqXHR.status == 400) {
$("#register_error")
.html(jqXHR.responseText ? jqXHR.responseText : "${_('An error occurred. Please try again later.')}")
.css("display", "block");
}
else if (jqXHR.status == 403) {
location.href = "${reg_then_add_to_cart_link}";
}
};
$("#add_to_cart_post").click(function(event){
$.ajax({
url: "${reverse('add_course_to_cart', args=[course.id])}",
type: "POST",
/* Rant: HAD TO USE COMPLETE B/C PROMISE.DONE FOR SOME REASON DOES NOT WORK ON THIS PAGE. */
complete: add_course_complete_handler
})
event.preventDefault();
});
## making the conditional around this entire JS block for sanity
%if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
......@@ -88,19 +113,42 @@
<div class="main-cta">
%if user.is_authenticated() and registered:
%if show_courseware_link:
<a href="${course_target}">
%endif
%if show_courseware_link:
<a href="${course_target}">
%endif
<span class="register disabled">${_("You are registered for this course {course.display_number_with_default}").format(course=course) | h}</span>
%if show_courseware_link:
<strong>${_("View Courseware")}</strong>
</a>
%endif
<span class="register disabled">
${_("You are registered for this course {course.display_number_with_default}").format(course=course) | h}
</span>
%if show_courseware_link:
<strong>${_("View Courseware")}</strong>
</a>
%endif
%elif in_cart:
<span class="add-to-cart">
${_('This course is in your <a href="{cart_link}">cart</a>.').format(cart_link=cart_link)}
</span>
%elif settings.MITX_FEATURES.get('ENABLE_PAID_COURSE_REGISTRATION') and registration_price:
<%
if user.is_authenticated():
reg_href = "#"
reg_element_id = "add_to_cart_post"
else:
reg_href = reg_then_add_to_cart_link
reg_element_id = "reg_then_add_to_cart"
%>
<a href="${reg_href}" class="add-to-cart" id="${reg_element_id}">
${_("Add {course.display_number_with_default} to Cart ({currency_symbol}{cost})")\
.format(course=course, currency_symbol=settings.PAID_COURSE_REGISTRATION_CURRENCY[1],
cost=registration_price)}
</a>
<div id="register_error"></div>
%else:
<a href="#" class="register">${_("Register for {course.display_number_with_default}").format(course=course) | h}</a>
<div id="register_error"></div>
<a href="#" class="register">
${_("Register for {course.display_number_with_default}").format(course=course) | h}
</a>
<div id="register_error"></div>
%endif
</div>
......
<%! from django.utils.translation import ugettext as _ %>
${_("Hi {name}").format(name=order.user.profile.name)}
${_("Your payment was successful. You will see the charge below on your next credit or debit card statement. The charge will show up on your statement under the company name {platform_name}. If you have billing questions, please read the FAQ ({faq_url}) or contact {billing_email}.").format(platform_name=settings.PLATFORM_NAME, billing_email=settings.PAYMENT_SUPPORT_EMAIL, faq_url=marketing_link('FAQ'))}
${_("Your payment was successful. You will see the charge below on your next credit or debit card statement.")}
${_("The charge will show up on your statement under the company name {merchant_name}.").format(merchant_name=settings.CC_MERCHANT_NAME)}
% if marketing_link('FAQ'):
${_("If you have billing questions, please read the FAQ ({faq_url}) or contact {billing_email}.").format(billing_email=settings.PAYMENT_SUPPORT_EMAIL, faq_url=marketing_link('FAQ'))}
% else:
${_("If you have billing questions, please contact {billing_email}.").format(billing_email=settings.PAYMENT_SUPPORT_EMAIL)}
% endif
${_("-The {platform_name} Team").format(platform_name=settings.PLATFORM_NAME)}
${_("Your order number is: {order_number}").format(order_number=order.id)}
......@@ -13,8 +18,18 @@ ${_("Quantity - Description - Price")}
%for order_item in order_items:
${order_item.qty} - ${order_item.line_desc} - ${"$" if order_item.currency == 'usd' else ""}${order_item.line_cost}
%endfor
${_("Total billed to credit/debit card: {currency_symbol}{total_cost}").format(total_cost=order.total_cost, currency_symbol=("$" if order.currency == 'usd' else ""))}
% if has_billing_info:
${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()}
% endif
%for order_item in order_items:
${order_item.additional_instruction_text}
%endfor
......@@ -9,6 +9,8 @@ from django.utils.translation import ugettext as _
import branding
# app that handles site status messages
from status.status import get_site_status_msg
# shopping cart
import shoppingcart
%>
## Provide a hook for themes to inject branding on top.
......@@ -79,7 +81,17 @@ site_status_msg = get_site_status_msg(course_id)
</ul>
</li>
</ol>
% if settings.MITX_FEATURES.get('ENABLE_PAID_COURSE_REGISTRATION') and \
settings.MITX_FEATURES['ENABLE_SHOPPING_CART'] and \
shoppingcart.models.Order.user_cart_has_items(user):
<ol class="user">
<li class="primary">
<a class="shopping-cart" href="${reverse('shoppingcart.views.show_cart')}">
<i class="icon-shopping-cart"></i> Shopping Cart
</a>
</li>
</ol>
% endif
% else:
<ol class="left nav-global">
<%block name="navigation_global_links">
......
......@@ -7,47 +7,62 @@
<%block name="title"><title>${_("Your Shopping Cart")}</title></%block>
<section class="container cart-list">
<h2>${_("Your selected items:")}</h2>
% if shoppingcart_items:
<table>
<thead>
<tr>${_("<td>Quantity</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr>
</thead>
<tbody>
% for item in shoppingcart_items:
<tr><td>${item.qty}</td><td>${item.line_desc}</td>
<td>${"{0:0.2f}".format(item.unit_cost)}</td><td>${"{0:0.2f}".format(item.line_cost)}</td>
<td>${item.currency.upper()}</td>
<td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td></tr>
% endfor
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr>
<tr><td></td><td></td><td></td><td>${"{0:0.2f}".format(amount)}</td></tr>
</tbody>
</table>
<!-- <input id="back_input" type="submit" value="Return" /> -->
${form_html}
% else:
<p>${_("You have selected no items for purchase.")}</p>
% endif
<h2>${_("Your selected items:")}</h2>
% if shoppingcart_items:
<table class="cart-table">
<thead>
<tr class="cart-headings">
<th class="qty">${_("Quantity")}</th>
<th class="dsc">${_("Description")}</th>
<th class="u-pr">${_("Unit Price")}</th>
<th class="prc">${_("Price")}</th>
<th class="cur">${_("Currency")}</th>
</tr>
</thead>
<tbody>
% for item in shoppingcart_items:
<tr class="cart-items">
<td>${item.qty}</td>
<td>${item.line_desc}</td>
<td>${"{0:0.2f}".format(item.unit_cost)}</td>
<td>${"{0:0.2f}".format(item.line_cost)}</td>
<td>${item.currency.upper()}</td>
<td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td>
</tr>
% endfor
<tr class="cart-headings">
<td colspan="4"></td>
<th>${_("Total Amount")}</th>
</tr>
<tr class="cart-totals">
<td colspan="4"></td>
<td class="cart-total-cost">${"{0:0.2f}".format(amount)}</td>
</tr>
</tbody>
</table>
<!-- <input id="back_input" type="submit" value="Return" /> -->
${form_html}
% else:
<p>${_("You have selected no items for purchase.")}</p>
% endif
</section>
<script>
$(function() {
$('a.remove_line_item').click(function(event) {
event.preventDefault();
var post_url = "${reverse('shoppingcart.views.remove_item')}";
$.post(post_url, {id:$(this).data('item-id')})
.always(function(data){
location.reload(true);
});
});
$('#back_input').click(function(){
history.back();
});
$(function() {
$('a.remove_line_item').click(function(event) {
event.preventDefault();
var post_url = "${reverse('shoppingcart.views.remove_item')}";
$.post(post_url, {id:$(this).data('item-id')})
.always(function(data){
location.reload(true);
});
});
$('#back_input').click(function(){
history.back();
});
});
</script>
......@@ -3,70 +3,89 @@
<%! from django.conf import settings %>
<%inherit file="../main.html" />
<%block name="bodyclass">register verification-process step-requirements</%block>
<%block name="bodyclass">purchase-receipt</%block>
<%block name="title"><title>${_("Register for [Course Name] | Receipt (Order")} ${order.id})</title></%block>
<%block name="content">
% if notification is not UNDEFINED:
<section class="notification">
${notification}
</section>
% endif
<div class="container">
<section class="wrapper cart-list">
<section class="notification">
<h2>Thank you for your Purchase!</h2>
<p>Please print this receipt page for your records. You should also have received a receipt in your email.</p>
% for inst in instructions:
<p>${inst}</p>
% endfor
</section>
<section class="wrapper cart-list">
<div class="wrapper-content-main">
<article class="content-main">
<h3 class="title">${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}</h3>
<h2>${_("Order #")}${order.id}</h2>
<h2>${_("Date:")} ${order.purchase_time.date().isoformat()}</h2>
<h2>${_("Items ordered:")}</h2>
<h1>${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}</h1>
<hr />
<table>
<thead>
<tr>${_("<td>Qty</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr>
</thead>
<tbody>
% for item in order_items:
<tr>
% if item.status == "purchased":
<td>${item.qty}</td><td>${item.line_desc}</td>
<table class="order-receipt">
<tbody>
<tr>
<td colspan="2"><h3 class="order-number">${_("Order #")}${order.id}</h3></td>
<td></td>
<td colspan="2"><h3 class="order-date">${_("Date:")} ${order.purchase_time.date().isoformat()}</h3></td>
</tr>
<tr>
<td colspan="5"><h2 class="items-ordered">${_("Items ordered:")}</h2></td>
</tr>
<tr>
<th class="qty">${_("Qty")}</th>
<th class="desc">${_("Description")}</th>
<th class="u-pr">${_("Unit Price")}</th>
<th class="pri">${_("Price")}</th>
<th class="curr">${_("Currency")}</th>
</tr>
% for item in order_items:
<tr class="order-item">
% if item.status == "purchased":
<td>${item.qty}</td>
<td>${item.line_desc}</td>
<td>${"{0:0.2f}".format(item.unit_cost)}</td>
<td>${"{0:0.2f}".format(item.line_cost)}</td>
<td>${item.currency.upper()}</td></tr>
% elif item.status == "refunded":
<td><del>${item.qty}</del></td><td><del>${item.line_desc}</del></td>
% elif item.status == "refunded":
<td><del>${item.qty}</del></td>
<td><del>${item.line_desc}</del></td>
<td><del>${"{0:0.2f}".format(item.unit_cost)}</del></td>
<td><del>${"{0:0.2f}".format(item.line_cost)}</del></td>
<td><del>${item.currency.upper()}</del></td></tr>
% endif
% endfor
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr>
<tr><td></td><td></td><td></td><td>${"{0:0.2f}".format(order.total_cost)}</td></tr>
</tbody>
% endif
% endfor
<tr>
<td colspan="3"></td>
<th>${_("Total Amount")}</th>
<td></td>
</tr>
<tr>
<td colspan="3"></td>
<td>${"{0:0.2f}".format(order.total_cost)}</td>
<td></td>
</tr>
</tbody>
</table>
% if any_refunds:
<p>
${_("Note: items with strikethough like ")}<del>this</del>${_(" have been refunded.")}
</p>
% endif
<h2>${_("Billed To:")}</h2>
<p>
${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}<br />
${order.bill_to_first} ${order.bill_to_last}<br />
${order.bill_to_street1}<br />
${order.bill_to_street2}<br />
${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}<br />
${order.bill_to_country.upper()}<br />
</p>
% if any_refunds:
<p>
${_("Note: items with strikethough like ")}<del>this</del>${_(" have been refunded.")}
</p>
% endif
<h2>${_("Billed To:")}</h2>
<p>
${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}<br />
${order.bill_to_first} ${order.bill_to_last}<br />
${order.bill_to_street1}<br />
${order.bill_to_street2}<br />
${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}<br />
${order.bill_to_country.upper()}<br />
</p>
</div>
</section>
</div>
</%block>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment