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): ...@@ -37,7 +37,7 @@ class CourseMode(models.Model):
currency = models.CharField(default="usd", max_length=8) currency = models.CharField(default="usd", max_length=8)
# turn this mode off after the given expiration date # 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 = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd')
DEFAULT_MODE_SLUG = 'honor' DEFAULT_MODE_SLUG = 'honor'
...@@ -86,6 +86,15 @@ class CourseMode(models.Model): ...@@ -86,6 +86,15 @@ class CourseMode(models.Model):
else: else:
return None 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): def __unicode__(self):
return u"{} : {}, min={}, prices={}".format( return u"{} : {}, min={}, prices={}".format(
self.course_id, self.mode_slug, self.min_price, self.suggested_prices self.course_id, self.mode_slug, self.min_price, self.suggested_prices
......
...@@ -73,6 +73,24 @@ class CourseModeModelTest(TestCase): ...@@ -73,6 +73,24 @@ class CourseModeModelTest(TestCase):
self.assertEqual(mode2, CourseMode.mode_for_course(self.course_id, u'verified')) self.assertEqual(mode2, CourseMode.mode_for_course(self.course_id, u'verified'))
self.assertIsNone(CourseMode.mode_for_course(self.course_id, 'DNE')) 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): def test_modes_for_course_expired(self):
expired_mode, _status = self.create_mode('verified', 'Verified Certificate') expired_mode, _status = self.create_mode('verified', 'Verified Certificate')
expired_mode.expiration_date = datetime.now(pytz.UTC) + timedelta(days=-1) expired_mode.expiration_date = datetime.now(pytz.UTC) + timedelta(days=-1)
......
...@@ -11,20 +11,29 @@ import unittest ...@@ -11,20 +11,29 @@ import unittest
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.hashers import UNUSABLE_PASSWORD from django.contrib.auth.hashers import UNUSABLE_PASSWORD
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.utils.http import int_to_base36 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 mock import Mock, patch
from textwrap import dedent from textwrap import dedent
from student.models import unique_id_for_user, CourseEnrollment 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.factories import UserFactory
from student.tests.test_email import mock_render_to_string from student.tests.test_email import mock_render_to_string
import shoppingcart
COURSE_1 = 'edX/toy/2012_Fall' COURSE_1 = 'edX/toy/2012_Fall'
COURSE_2 = 'edx/full/6.002_Spring_2012' COURSE_2 = 'edx/full/6.002_Spring_2012'
...@@ -343,3 +352,32 @@ class EnrollInCourseTest(TestCase): ...@@ -343,3 +352,32 @@ class EnrollInCourseTest(TestCase):
# for that user/course_id combination # for that user/course_id combination
CourseEnrollment.enroll(user, course_id) CourseEnrollment.enroll(user, course_id)
self.assertTrue(CourseEnrollment.is_enrolled(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 ...@@ -58,6 +58,7 @@ from external_auth.models import ExternalAuthMap
import external_auth.views import external_auth.views
from bulk_email.models import Optout from bulk_email.models import Optout
import shoppingcart
import track.views import track.views
...@@ -405,6 +406,19 @@ def change_enrollment(request): ...@@ -405,6 +406,19 @@ def change_enrollment(request):
return HttpResponse() 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": elif action == "unenroll":
try: try:
CourseEnrollment.unenroll(user, course_id) CourseEnrollment.unenroll(user, course_id)
......
from mock import MagicMock """
Tests courseware views.py
"""
from mock import MagicMock, patch
import datetime import datetime
import unittest
from django.test import TestCase from django.test import TestCase
from django.http import Http404 from django.http import Http404
from django.test.utils import override_settings 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.test.client import RequestFactory
from django.conf import settings from django.conf import settings
...@@ -15,12 +19,14 @@ from student.tests.factories import AdminFactory ...@@ -15,12 +19,14 @@ from student.tests.factories import AdminFactory
from mitxmako.middleware import MakoMiddleware from mitxmako.middleware import MakoMiddleware
from xmodule.modulestore.django import modulestore, clear_existing_modulestores from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.tests.factories import CourseFactory
import courseware.views as views import courseware.views as views
from xmodule.modulestore import Location from xmodule.modulestore import Location
from pytz import UTC from pytz import UTC
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from course_modes.models import CourseMode from course_modes.models import CourseMode
import shoppingcart
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
...@@ -78,6 +84,32 @@ class ViewsTestCase(TestCase): ...@@ -78,6 +84,32 @@ class ViewsTestCase(TestCase):
chapter = 'Overview' chapter = 'Overview'
self.chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) 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): def test_user_groups(self):
# depreciated function # depreciated function
mock_user = MagicMock() mock_user = MagicMock()
......
...@@ -36,6 +36,7 @@ from xmodule.modulestore.django import modulestore ...@@ -36,6 +36,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
from xmodule.modulestore.search import path_to_location from xmodule.modulestore.search import path_to_location
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
import shoppingcart
import comment_client import comment_client
...@@ -604,10 +605,27 @@ def course_about(request, course_id): ...@@ -604,10 +605,27 @@ def course_about(request, course_id):
show_courseware_link = (has_access(request.user, course, 'load') or show_courseware_link = (has_access(request.user, course, 'load') or
settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION')) 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', return render_to_response('courseware/course_about.html',
{'course': course, {'course': course,
'registered': registered, 'registered': registered,
'course_target': course_target, '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}) '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): class PaymentException(Exception):
pass pass
...@@ -8,3 +14,15 @@ class PurchasedCallbackException(PaymentException): ...@@ -8,3 +14,15 @@ class PurchasedCallbackException(PaymentException):
class InvalidCartItem(PaymentException): class InvalidCartItem(PaymentException):
pass pass
class ItemAlreadyInCartException(InvalidCartItem):
pass
class AlreadyEnrolledInCourseException(InvalidCartItem):
pass
class CourseDoesNotExistException(InvalidCartItem):
pass
""" """
Tests for the Shopping Cart Models 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, MagicMock
from mock import patch
from django.core import mail from django.core import mail
from django.conf import settings from django.conf import settings
from django.db import DatabaseError from django.db import DatabaseError
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings 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.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE 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.tests.factories import UserFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
from course_modes.models import CourseMode from course_modes.models import CourseMode
...@@ -39,13 +41,24 @@ class OrderTest(ModuleStoreTestCase): ...@@ -39,13 +41,24 @@ class OrderTest(ModuleStoreTestCase):
cart2 = Order.get_cart_for_user(user=self.user) cart2 = Order.get_cart_for_user(user=self.user)
self.assertEquals(cart2.orderitem_set.count(), 1) 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): def test_cart_clear(self):
cart = Order.get_cart_for_user(user=self.user) 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, self.course_id, self.cost, 'honor')
CertificateItem.add_to_order(cart, 'org/test/Test_Course_1', self.cost, 'honor') CertificateItem.add_to_order(cart, 'org/test/Test_Course_1', self.cost, 'honor')
self.assertEquals(cart.orderitem_set.count(), 2) self.assertEquals(cart.orderitem_set.count(), 2)
self.assertTrue(cart.has_items())
cart.clear() cart.clear()
self.assertEquals(cart.orderitem_set.count(), 0) self.assertEquals(cart.orderitem_set.count(), 0)
self.assertFalse(cart.has_items())
def test_add_item_to_cart_currency_match(self): def test_add_item_to_cart_currency_match(self):
cart = Order.get_cart_for_user(user=self.user) cart = Order.get_cart_for_user(user=self.user)
...@@ -111,6 +124,22 @@ class OrderTest(ModuleStoreTestCase): ...@@ -111,6 +124,22 @@ class OrderTest(ModuleStoreTestCase):
cart.purchase() cart.purchase()
self.assertEquals(len(mail.outbox), 1) 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): def purchase_with_data(self, cart):
""" purchase a cart with billing information """ """ purchase a cart with billing information """
CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor') CertificateItem.add_to_order(cart, self.course_id, self.cost, 'honor')
...@@ -127,8 +156,9 @@ class OrderTest(ModuleStoreTestCase): ...@@ -127,8 +156,9 @@ class OrderTest(ModuleStoreTestCase):
cardtype='001', cardtype='001',
) )
@patch('shoppingcart.models.render_to_string')
@patch.dict(settings.MITX_FEATURES, {'STORE_BILLING_INFO': True}) @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) cart = Order.get_cart_for_user(self.user)
self.purchase_with_data(cart) self.purchase_with_data(cart)
self.assertNotEqual(cart.bill_to_first, '') self.assertNotEqual(cart.bill_to_first, '')
...@@ -141,9 +171,12 @@ class OrderTest(ModuleStoreTestCase): ...@@ -141,9 +171,12 @@ class OrderTest(ModuleStoreTestCase):
self.assertNotEqual(cart.bill_to_city, '') self.assertNotEqual(cart.bill_to_city, '')
self.assertNotEqual(cart.bill_to_state, '') self.assertNotEqual(cart.bill_to_state, '')
self.assertNotEqual(cart.bill_to_country, '') 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}) @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) cart = Order.get_cart_for_user(self.user)
self.purchase_with_data(cart) self.purchase_with_data(cart)
self.assertNotEqual(cart.bill_to_first, '') self.assertNotEqual(cart.bill_to_first, '')
...@@ -157,13 +190,30 @@ class OrderTest(ModuleStoreTestCase): ...@@ -157,13 +190,30 @@ class OrderTest(ModuleStoreTestCase):
self.assertEqual(cart.bill_to_street2, '') self.assertEqual(cart.bill_to_street2, '')
self.assertEqual(cart.bill_to_ccnum, '') self.assertEqual(cart.bill_to_ccnum, '')
self.assertEqual(cart.bill_to_cardtype, '') 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): class OrderItemTest(TestCase):
def setUp(self): def setUp(self):
self.user = UserFactory.create() 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 This tests that calling purchased_callback on the base OrderItem class raises NotImplementedError
""" """
...@@ -171,6 +221,19 @@ class OrderItemTest(TestCase): ...@@ -171,6 +221,19 @@ class OrderItemTest(TestCase):
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
item.purchased_callback() 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) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class PaidCourseRegistrationTest(ModuleStoreTestCase): class PaidCourseRegistrationTest(ModuleStoreTestCase):
...@@ -195,8 +258,8 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): ...@@ -195,8 +258,8 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
self.assertEqual(reg1.mode, "honor") self.assertEqual(reg1.mode, "honor")
self.assertEqual(reg1.user, self.user) self.assertEqual(reg1.user, self.user)
self.assertEqual(reg1.status, "cart") self.assertEqual(reg1.status, "cart")
self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) self.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_id))
self.assertFalse(PaidCourseRegistration.part_of_order(self.cart, self.course_id + "abcd")) self.assertFalse(PaidCourseRegistration.contained_in_order(self.cart, self.course_id + "abcd"))
self.assertEqual(self.cart.total_cost, self.cost) self.assertEqual(self.cart.total_cost, self.cost)
def test_add_with_default_mode(self): def test_add_with_default_mode(self):
...@@ -212,7 +275,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): ...@@ -212,7 +275,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
self.assertEqual(reg1.user, self.user) self.assertEqual(reg1.user, self.user)
self.assertEqual(reg1.status, "cart") self.assertEqual(reg1.status, "cart")
self.assertEqual(self.cart.total_cost, 0) 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): def test_purchased_callback(self):
reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id)
...@@ -221,6 +284,26 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): ...@@ -221,6 +284,26 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
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") 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): def test_purchased_callback_exception(self):
reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id)
reg1.course_id = "changedforsomereason" reg1.course_id = "changedforsomereason"
......
...@@ -85,7 +85,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): ...@@ -85,7 +85,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
self.login_user() self.login_user()
resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id]))
self.assertEqual(resp.status_code, 200) 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) @patch('shoppingcart.views.render_purchase_form_html', form_mock)
......
...@@ -6,30 +6,33 @@ from django.views.decorators.http import require_POST ...@@ -6,30 +6,33 @@ from django.views.decorators.http import require_POST
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required 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 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 .processors import process_postpay_callback, render_purchase_form_html
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException
log = logging.getLogger("shoppingcart") log = logging.getLogger("shoppingcart")
@require_POST
def add_course_to_cart(request, course_id): 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(): 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')) return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart'))
cart = Order.get_cart_for_user(request.user) cart = Order.get_cart_for_user(request.user)
if PaidCourseRegistration.part_of_order(cart, course_id): # All logging from here handled by the model
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)))
try: try:
PaidCourseRegistration.add_to_order(cart, course_id) PaidCourseRegistration.add_to_order(cart, course_id)
except ItemNotFoundError: except CourseDoesNotExistException:
return HttpResponseNotFound(_('The course you requested does not exist.')) 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 except ItemAlreadyInCartException:
return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) 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.")) return HttpResponse(_("Course added to cart."))
...@@ -103,12 +106,14 @@ def show_receipt(request, ordernum): ...@@ -103,12 +106,14 @@ def show_receipt(request, ordernum):
order_items = OrderItem.objects.filter(order=order).select_subclasses() order_items = OrderItem.objects.filter(order=order).select_subclasses()
any_refunds = any(i.status == "refunded" for i in order_items) any_refunds = any(i.status == "refunded" for i in order_items)
receipt_template = 'shoppingcart/receipt.html' receipt_template = 'shoppingcart/receipt.html'
__, instructions = order.generate_receipt_instructions()
# we want to have the ability to override the default receipt page when # we want to have the ability to override the default receipt page when
# there is only one item in the order # there is only one item in the order
context = { context = {
'order': order, 'order': order,
'order_items': order_items, 'order_items': order_items,
'any_refunds': any_refunds, 'any_refunds': any_refunds,
'instructions': instructions,
} }
if order_items.count() == 1: if order_items.count() == 1:
......
...@@ -100,6 +100,8 @@ with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file: ...@@ -100,6 +100,8 @@ with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
ENV_TOKENS = json.load(env_file) ENV_TOKENS = json.load(env_file)
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME) 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_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND)
EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None) EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None)
EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost 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) ...@@ -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) CONTACT_EMAIL = ENV_TOKENS.get('CONTACT_EMAIL', CONTACT_EMAIL)
BUGS_EMAIL = ENV_TOKENS.get('BUGS_EMAIL', BUGS_EMAIL) BUGS_EMAIL = ENV_TOKENS.get('BUGS_EMAIL', BUGS_EMAIL)
PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_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 overrides
THEME_NAME = ENV_TOKENS.get('THEME_NAME', None) THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
......
...@@ -36,6 +36,7 @@ from xmodule.modulestore.inheritance import InheritanceMixin ...@@ -36,6 +36,7 @@ from xmodule.modulestore.inheritance import InheritanceMixin
################################### FEATURES ################################### ################################### FEATURES ###################################
# The display name of the platform to be used in templates/emails/etc. # The display name of the platform to be used in templates/emails/etc.
PLATFORM_NAME = "edX" PLATFORM_NAME = "edX"
CC_MERCHANT_NAME = PLATFORM_NAME
COURSEWARE_ENABLED = True COURSEWARE_ENABLED = True
ENABLE_JASMINE = False ENABLE_JASMINE = False
...@@ -171,6 +172,9 @@ MITX_FEATURES = { ...@@ -171,6 +172,9 @@ MITX_FEATURES = {
# Toggle storing detailed billing information # Toggle storing detailed billing information
'STORE_BILLING_INFO': False, '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 # Used for A/B testing
...@@ -500,7 +504,8 @@ CC_PROCESSOR = { ...@@ -500,7 +504,8 @@ CC_PROCESSOR = {
'PURCHASE_ENDPOINT': '', 'PURCHASE_ENDPOINT': '',
} }
} }
# Setting for PAID_COURSE_REGISTRATION, DOES NOT AFFECT VERIFIED STUDENTS
PAID_COURSE_REGISTRATION_CURRENCY = ['usd', '$']
################################# open ended grading config ##################### ################################# open ended grading config #####################
#By setting up the default settings with an incorrect user name and password, #By setting up the default settings with an incorrect user name and password,
......
...@@ -165,7 +165,6 @@ OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] ...@@ -165,7 +165,6 @@ OPENID_PROVIDER_TRUSTED_ROOTS = ['*']
###################### Payment ##############################3 ###################### Payment ##############################3
# Enable fake payment processing page # Enable fake payment processing page
MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
# Configure the payment processor to use the fake processing page # Configure the payment processor to use the fake processing page
# Since both the fake payment page and the shoppingcart app are using # Since both the fake payment page and the shoppingcart app are using
# the same settings, we can generate this randomly and guarantee # the same settings, we can generate this randomly and guarantee
......
...@@ -48,6 +48,7 @@ ...@@ -48,6 +48,7 @@
// base - specific views // base - specific views
@import 'views/verification'; @import 'views/verification';
@import 'views/shoppingcart';
// shared - course // shared - course
@import 'shared/forms'; @import 'shared/forms';
......
...@@ -98,7 +98,7 @@ ...@@ -98,7 +98,7 @@
@include transition(all 0.15s linear 0s); @include transition(all 0.15s linear 0s);
width: flex-grid(12); width: flex-grid(12);
> a.find-courses, a.register { > a.find-courses, a.register, a.add-to-cart {
@include button(shiny, $button-color); @include button(shiny, $button-color);
@include box-sizing(border-box); @include box-sizing(border-box);
border-radius: 3px; border-radius: 3px;
...@@ -139,7 +139,7 @@ ...@@ -139,7 +139,7 @@
} }
} }
span.register { span.register, span.add-to-cart {
background: $button-archive-color; background: $button-archive-color;
border: 1px solid darken($button-archive-color, 50%); border: 1px solid darken($button-archive-color, 50%);
@include box-sizing(border-box); @include box-sizing(border-box);
......
...@@ -123,6 +123,13 @@ header.global { ...@@ -123,6 +123,13 @@ header.global {
border-radius: 0 4px 4px 0; border-radius: 0 4px 4px 0;
border-left: none; border-left: none;
padding: 5px 8px 7px 8px; 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 @@ ...@@ -3,6 +3,8 @@
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from courseware.courses import course_image_url, get_course_about_section from courseware.courses import course_image_url, get_course_about_section
from courseware.access import has_access from courseware.access import has_access
cart_link = reverse('shoppingcart.views.show_cart')
%> %>
<%namespace name='static' file='../static_content.html'/> <%namespace name='static' file='../static_content.html'/>
...@@ -24,6 +26,29 @@ ...@@ -24,6 +26,29 @@
$("#class_enroll_form").submit(); $("#class_enroll_form").submit();
event.preventDefault(); 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 ## making the conditional around this entire JS block for sanity
%if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: %if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
...@@ -88,19 +113,42 @@ ...@@ -88,19 +113,42 @@
<div class="main-cta"> <div class="main-cta">
%if user.is_authenticated() and registered: %if user.is_authenticated() and registered:
%if show_courseware_link: %if show_courseware_link:
<a href="${course_target}"> <a href="${course_target}">
%endif %endif
<span class="register disabled">${_("You are registered for this course {course.display_number_with_default}").format(course=course) | h}</span> <span class="register disabled">
%if show_courseware_link: ${_("You are registered for this course {course.display_number_with_default}").format(course=course) | h}
<strong>${_("View Courseware")}</strong> </span>
</a> %if show_courseware_link:
%endif <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: %else:
<a href="#" class="register">${_("Register for {course.display_number_with_default}").format(course=course) | h}</a> <a href="#" class="register">
<div id="register_error"></div> ${_("Register for {course.display_number_with_default}").format(course=course) | h}
</a>
<div id="register_error"></div>
%endif %endif
</div> </div>
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
${_("Hi {name}").format(name=order.user.profile.name)} ${_("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)} ${_("-The {platform_name} Team").format(platform_name=settings.PLATFORM_NAME)}
${_("Your order number is: {order_number}").format(order_number=order.id)} ${_("Your order number is: {order_number}").format(order_number=order.id)}
...@@ -13,8 +18,18 @@ ${_("Quantity - Description - Price")} ...@@ -13,8 +18,18 @@ ${_("Quantity - Description - Price")}
%for order_item in order_items: %for order_item in order_items:
${order_item.qty} - ${order_item.line_desc} - ${"$" if order_item.currency == 'usd' else ""}${order_item.line_cost} ${order_item.qty} - ${order_item.line_desc} - ${"$" if order_item.currency == 'usd' else ""}${order_item.line_cost}
%endfor %endfor
${_("Total billed to credit/debit card: {currency_symbol}{total_cost}").format(total_cost=order.total_cost, currency_symbol=("$" if order.currency == 'usd' else ""))} ${_("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: %for order_item in order_items:
${order_item.additional_instruction_text} ${order_item.additional_instruction_text}
%endfor %endfor
...@@ -9,6 +9,8 @@ from django.utils.translation import ugettext as _ ...@@ -9,6 +9,8 @@ from django.utils.translation import ugettext as _
import branding import branding
# app that handles site status messages # app that handles site status messages
from status.status import get_site_status_msg from status.status import get_site_status_msg
# shopping cart
import shoppingcart
%> %>
## Provide a hook for themes to inject branding on top. ## Provide a hook for themes to inject branding on top.
...@@ -79,7 +81,17 @@ site_status_msg = get_site_status_msg(course_id) ...@@ -79,7 +81,17 @@ site_status_msg = get_site_status_msg(course_id)
</ul> </ul>
</li> </li>
</ol> </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: % else:
<ol class="left nav-global"> <ol class="left nav-global">
<%block name="navigation_global_links"> <%block name="navigation_global_links">
......
...@@ -7,47 +7,62 @@ ...@@ -7,47 +7,62 @@
<%block name="title"><title>${_("Your Shopping Cart")}</title></%block> <%block name="title"><title>${_("Your Shopping Cart")}</title></%block>
<section class="container cart-list"> <section class="container cart-list">
<h2>${_("Your selected items:")}</h2> <h2>${_("Your selected items:")}</h2>
% if shoppingcart_items: % if shoppingcart_items:
<table> <table class="cart-table">
<thead> <thead>
<tr>${_("<td>Quantity</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr> <tr class="cart-headings">
</thead> <th class="qty">${_("Quantity")}</th>
<tbody> <th class="dsc">${_("Description")}</th>
% for item in shoppingcart_items: <th class="u-pr">${_("Unit Price")}</th>
<tr><td>${item.qty}</td><td>${item.line_desc}</td> <th class="prc">${_("Price")}</th>
<td>${"{0:0.2f}".format(item.unit_cost)}</td><td>${"{0:0.2f}".format(item.line_cost)}</td> <th class="cur">${_("Currency")}</th>
<td>${item.currency.upper()}</td> </tr>
<td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td></tr> </thead>
% endfor <tbody>
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr> % for item in shoppingcart_items:
<tr><td></td><td></td><td></td><td>${"{0:0.2f}".format(amount)}</td></tr> <tr class="cart-items">
<td>${item.qty}</td>
</tbody> <td>${item.line_desc}</td>
</table> <td>${"{0:0.2f}".format(item.unit_cost)}</td>
<!-- <input id="back_input" type="submit" value="Return" /> --> <td>${"{0:0.2f}".format(item.line_cost)}</td>
${form_html} <td>${item.currency.upper()}</td>
% else: <td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td>
<p>${_("You have selected no items for purchase.")}</p> </tr>
% endif % 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> </section>
<script> <script>
$(function() { $(function() {
$('a.remove_line_item').click(function(event) { $('a.remove_line_item').click(function(event) {
event.preventDefault(); event.preventDefault();
var post_url = "${reverse('shoppingcart.views.remove_item')}"; var post_url = "${reverse('shoppingcart.views.remove_item')}";
$.post(post_url, {id:$(this).data('item-id')}) $.post(post_url, {id:$(this).data('item-id')})
.always(function(data){ .always(function(data){
location.reload(true); location.reload(true);
}); });
});
$('#back_input').click(function(){
history.back();
});
}); });
$('#back_input').click(function(){
history.back();
});
});
</script> </script>
...@@ -3,70 +3,89 @@ ...@@ -3,70 +3,89 @@
<%! from django.conf import settings %> <%! from django.conf import settings %>
<%inherit file="../main.html" /> <%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="title"><title>${_("Register for [Course Name] | Receipt (Order")} ${order.id})</title></%block>
<%block name="content"> <%block name="content">
% if notification is not UNDEFINED:
<section class="notification">
${notification}
</section>
% endif
<div class="container"> <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"> <div class="wrapper-content-main">
<article class="content-main"> <article class="content-main">
<h3 class="title">${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}</h3> <h1>${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}</h1>
<hr />
<h2>${_("Order #")}${order.id}</h2>
<h2>${_("Date:")} ${order.purchase_time.date().isoformat()}</h2>
<h2>${_("Items ordered:")}</h2>
<table> <table class="order-receipt">
<thead> <tbody>
<tr>${_("<td>Qty</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr> <tr>
</thead> <td colspan="2"><h3 class="order-number">${_("Order #")}${order.id}</h3></td>
<tbody> <td></td>
% for item in order_items: <td colspan="2"><h3 class="order-date">${_("Date:")} ${order.purchase_time.date().isoformat()}</h3></td>
<tr> </tr>
% if item.status == "purchased": <tr>
<td>${item.qty}</td><td>${item.line_desc}</td> <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.unit_cost)}</td>
<td>${"{0:0.2f}".format(item.line_cost)}</td> <td>${"{0:0.2f}".format(item.line_cost)}</td>
<td>${item.currency.upper()}</td></tr> <td>${item.currency.upper()}</td></tr>
% elif item.status == "refunded": % elif item.status == "refunded":
<td><del>${item.qty}</del></td><td><del>${item.line_desc}</del></td> <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.unit_cost)}</del></td>
<td><del>${"{0:0.2f}".format(item.line_cost)}</del></td> <td><del>${"{0:0.2f}".format(item.line_cost)}</del></td>
<td><del>${item.currency.upper()}</del></td></tr> <td><del>${item.currency.upper()}</del></td></tr>
% endif % endif
% endfor % endfor
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr> <tr>
<tr><td></td><td></td><td></td><td>${"{0:0.2f}".format(order.total_cost)}</td></tr> <td colspan="3"></td>
</tbody> <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> </table>
% if any_refunds:
<p>
${_("Note: items with strikethough like ")}<del>this</del>${_(" have been refunded.")}
</p>
% endif
<h2>${_("Billed To:")}</h2> % if any_refunds:
<p> <p>
${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}<br /> ${_("Note: items with strikethough like ")}<del>this</del>${_(" have been refunded.")}
${order.bill_to_first} ${order.bill_to_last}<br /> </p>
${order.bill_to_street1}<br /> % endif
${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>
<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> </section>
</div> </div>
</%block> </%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