diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index cc25031935ff458d818a1760fa2b8ee9a2ae2d5b..fa1a687e47c627725208025b2a90945e8f148938 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -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 diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index 72aa3f2464c88c84160456b93e29cf86f6b1ee22..deeed6ea9ba04e43a9a26c936bcd0a3f457fcfff 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -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) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index a6466ee9f96ca13052e75e91f3e39223862404bb..c35ad664274df56e09f3ca0a5c9bf436cc3e5e82 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -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)) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 486e32c465fce77f2001a666b682abded9d5c124..db2ce5b4a452dea2e8108f0ee8c8ea73809f4829 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 1c87abd3bc6edd9dac3ae4b8be0749711e96c5ba..dbf4bf1994adf1d9a771910cd4eb5f298d92fc5f 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -1,10 +1,14 @@ -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() diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 66c91f1b9bea57f4e8ded391198a29c92de70d9c..4488b510d05c66e2a78341a11ec29ddbee057d64 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -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}) diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py index 029dc079bb27f0bb389d567f4adcb49a3a4748a6..a40c2e9feba92bfbf231403d9d565c74fb793c97 100644 --- a/lms/djangoapps/shoppingcart/exceptions.py +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -1,3 +1,9 @@ +""" +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 diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 368a327fc903959e816b280446da691eab47e0c3..9ae11f6554aada58bdb26815e61e9ff713f88aa7 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -2,7 +2,10 @@ from datetime import datetime import pytz import logging import smtplib -import textwrap + +from model_utils.managers import InheritanceManager +from collections import namedtuple +from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors from django.db import models from django.conf import settings @@ -11,19 +14,20 @@ from django.core.mail import send_mail from django.contrib.auth.models import User from django.utils.translation import ugettext as _ from django.db import transaction -from model_utils.managers import InheritanceManager +from django.core.urlresolvers import reverse + +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.exceptions import ItemNotFoundError from course_modes.models import CourseMode -from courseware.courses import get_course_about_section from mitxmako.shortcuts import render_to_string from student.views import course_from_id from student.models import CourseEnrollment -from dogapi import dog_stats_api from verify_student.models import SoftwareSecurePhotoVerification -from xmodule.modulestore.django import modulestore -from xmodule.course_module import CourseDescriptor -from .exceptions import InvalidCartItem, PurchasedCallbackException +from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException, + AlreadyEnrolledInCourseException, CourseDoesNotExistException) log = logging.getLogger("shoppingcart") @@ -33,6 +37,9 @@ ORDER_STATUSES = ( ('refunded', 'refunded'), # Not used for now ) +# we need a tuple to represent the primary key of various OrderItem subclasses +OrderItemSubclassPK = namedtuple('OrderItemSubclassPK', ['cls', 'pk']) # pylint: disable=C0103 + class Order(models.Model): """ @@ -72,13 +79,30 @@ class Order(models.Model): cart_order, _created = cls.objects.get_or_create(user=user, status='cart') return cart_order + @classmethod + def user_cart_has_items(cls, user): + """ + Returns true if the user (anonymous user ok) has + a cart with items in it. (Which means it should be displayed. + """ + if not user.is_authenticated(): + return False + cart = cls.get_cart_for_user(user) + return cart.has_items() + @property def total_cost(self): """ Return the total cost of the cart. If the order has been purchased, returns total of all purchased and not refunded items. """ - return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status)) + return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status)) # pylint: disable=E1101 + + def has_items(self): + """ + Does the cart have any items in it? + """ + return self.orderitem_set.exists() # pylint: disable=E1101 def clear(self): """ @@ -135,13 +159,31 @@ class Order(models.Model): subject = _("Order Payment Confirmation") message = render_to_string('emails/order_confirmation_email.txt', { 'order': self, - 'order_items': orderitems + 'order_items': orderitems, + 'has_billing_info': settings.MITX_FEATURES['STORE_BILLING_INFO'] }) try: send_mail(subject, message, - settings.DEFAULT_FROM_EMAIL, [self.user.email]) - except smtplib.SMTPException: - log.error('Failed sending confirmation e-mail for order %d', self.id) + settings.DEFAULT_FROM_EMAIL, [self.user.email]) # pylint: disable=E1101 + except (smtplib.SMTPException, BotoServerError): # sadly need to handle diff. mail backends individually + log.error('Failed sending confirmation e-mail for order %d', self.id) # pylint: disable=E1101 + + def generate_receipt_instructions(self): + """ + Call to generate specific instructions for each item in the order. This gets displayed on the receipt + page, typically. Instructions are something like "visit your dashboard to see your new courses". + This will return two things in a pair. The first will be a dict with keys=OrderItemSubclassPK corresponding + to an OrderItem and values=a set of html instructions they generate. The second will be a set of de-duped + html instructions + """ + instruction_set = set([]) # heh. not ia32 or alpha or sparc + instruction_dict = {} + order_items = OrderItem.objects.filter(order=self).select_subclasses() + for item in order_items: + item_pk_with_subclass, set_of_html = item.generate_receipt_instructions() + instruction_dict[item_pk_with_subclass] = set_of_html + instruction_set.update(set_of_html) + return instruction_dict, instruction_set class OrderItem(models.Model): @@ -202,6 +244,22 @@ class OrderItem(models.Model): """ raise NotImplementedError + def generate_receipt_instructions(self): + """ + This is called on each item in a purchased order to generate receipt instructions. + This should return a list of `ReceiptInstruction`s in HTML string + Default implementation is to return an empty set + """ + return self.pk_with_subclass, set([]) + + @property + def pk_with_subclass(self): + """ + Returns a named tuple that annotates the pk of this instance with its class, to fully represent + a pk of a subclass (inclusive) of OrderItem + """ + return OrderItemSubclassPK(type(self), self.pk) + @property def single_item_receipt_template(self): """ @@ -235,9 +293,9 @@ class PaidCourseRegistration(OrderItem): mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG) @classmethod - def part_of_order(cls, order, course_id): + def contained_in_order(cls, order, course_id): """ - Is the course defined by course_id in the order? + Is the course defined by course_id contained in the order? """ return course_id in [item.paidcourseregistration.course_id for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")] @@ -251,10 +309,26 @@ class PaidCourseRegistration(OrderItem): Returns the order item """ - # TODO: Possibly add checking for whether student is already enrolled in course - course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to - # throw errors if it doesn't - + # First a bunch of sanity checks + try: + course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + except ItemNotFoundError: + log.error("User {} tried to add non-existent course {} to cart id {}" + .format(order.user.email, course_id, order.id)) + raise CourseDoesNotExistException + + if cls.contained_in_order(order, course_id): + log.warning("User {} tried to add PaidCourseRegistration for course {}, already in cart id {}" + .format(order.user.email, course_id, order.id)) + raise ItemAlreadyInCartException + + if CourseEnrollment.is_enrolled(user=order.user, course_id=course_id): + log.warning("User {} trying to add course {} to cart id {}, already registered" + .format(order.user.email, course_id, order.id)) + raise AlreadyEnrolledInCourseException + + ### Validations done, now proceed ### handle default arguments for mode_slug, cost, currency course_mode = CourseMode.mode_for_course(course_id, mode_slug) if not course_mode: @@ -273,12 +347,13 @@ class PaidCourseRegistration(OrderItem): item.mode = course_mode.slug item.qty = 1 item.unit_cost = cost - item.line_desc = 'Registration for Course: {0}. Mode: {1}'.format(get_course_about_section(course, "title"), - course_mode.name) + item.line_desc = 'Registration for Course: {0}'.format(course.display_name_with_default) item.currency = currency order.currency = currency order.save() item.save() + log.info("User {} added course registration {} to cart: order {}" + .format(order.user.email, course_id, order.id)) return item def purchased_callback(self): @@ -301,14 +376,18 @@ class PaidCourseRegistration(OrderItem): CourseEnrollment.enroll(user=self.user, course_id=self.course_id, mode=self.mode) - log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) - org, course_num, run = self.course_id.split("/") - dog_stats_api.increment( - "shoppingcart.PaidCourseRegistration.purchased_callback.enrollment", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run)] - ) + log.info("Enrolled {0} in paid course {1}, paid ${2}" + .format(self.user.email, self.course_id, self.line_cost)) # pylint: disable=E1101 + + def generate_receipt_instructions(self): + """ + Generates instructions when the user has purchased a PaidCourseRegistration. + Basically tells the user to visit the dashboard to see their new classes + """ + notification = (_('Please visit your <a href="{dashboard_link}">dashboard</a> to see your new enrollments.') + .format(dashboard_link=reverse('dashboard'))) + + return self.pk_with_subclass, set([notification]) class CertificateItem(OrderItem): diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index f14599714593261ffea7368242b2ef23ffb9d971..1224bb5093b33b072c0eff7bb62dccfe27be9ff8 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -1,19 +1,21 @@ """ 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" diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 8c124901c11400f943fa7c40375489e4bc2c6879..d60cab78d90e3641ac3d1fe26d58b5a36d5dc0f1 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 8930136b802de06b2e096797e888083d2dd1fccf..8c6d61d532e530afaa3d44c897eab2308bd0b0ed 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -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: diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 1d012e4ca03566568cbb3adb370355a97e12eaae..86d3d539bd5ccc0c80eb336f962dea7bc3d04f34 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index 47f0083957ae261b36309e67f3ab20d6a0e517ce..941809b82fb121f12b788720f7366a9c7c6304ba 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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, diff --git a/lms/envs/test.py b/lms/envs/test.py index 45e934ec1f21883186a5c22fcae578416d19efa8..30475e26a0e847c6f62c9b8f23a7fb75bda44eb9 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -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 diff --git a/lms/static/sass/application.scss.mako b/lms/static/sass/application.scss.mako index 2753edda0cd3daa6ff417b137a6957aa7222fb16..a5007481218b385a5f1c1e5ab5831a8802a576a5 100644 --- a/lms/static/sass/application.scss.mako +++ b/lms/static/sass/application.scss.mako @@ -48,6 +48,7 @@ // base - specific views @import 'views/verification'; +@import 'views/shoppingcart'; // shared - course @import 'shared/forms'; diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss index 4f1831cc7ffc925fa560d2b068125599f40d48bf..26bafff87df93ecd5bc91f5c491542432ea66fd7 100644 --- a/lms/static/sass/multicourse/_course_about.scss +++ b/lms/static/sass/multicourse/_course_about.scss @@ -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); diff --git a/lms/static/sass/shared/_header.scss b/lms/static/sass/shared/_header.scss index a649cb43d95075cb5e5dcb2537ba4c015b3d8781..b3ca2077f0a9a2cc5a01eed751a0cc5509a9fd06 100644 --- a/lms/static/sass/shared/_header.scss +++ b/lms/static/sass/shared/_header.scss @@ -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; + } } } } diff --git a/lms/static/sass/views/_shoppingcart.scss b/lms/static/sass/views/_shoppingcart.scss new file mode 100644 index 0000000000000000000000000000000000000000..d6861fb45630036c1518b2e08c250dbf1a8d7803 --- /dev/null +++ b/lms/static/sass/views/_shoppingcart.scss @@ -0,0 +1,103 @@ +// 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 diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 6abcd2998a82ede82cc4b2d40403f478a3223326..e63003b3ee2658070cde06b5a28a0e0434c36cb4 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -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> diff --git a/lms/templates/emails/order_confirmation_email.txt b/lms/templates/emails/order_confirmation_email.txt index 74f945b06ad918a49909e296df61471c937daae2..65c0e3a67e71400f71d3942061b5fcf2956d758c 100644 --- a/lms/templates/emails/order_confirmation_email.txt +++ b/lms/templates/emails/order_confirmation_email.txt @@ -1,8 +1,13 @@ <%! 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 diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 1dd5aa5229f8ca678f1f7c26ec32df3e411c38cf..8f6da00e68d22d7bb0f4af35c7757aa0dca97c1d 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -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"> diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index cf452baab0de8c204d1b373e24fb6c5271e0dad7..820e05dc47bc4982b1aedf4616fda6d7e5edbbb3 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -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> diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html index 7802f88dea2c1e77a7d8a15fda51884556c8beab..20c8a4272cdafac8ba5f820e7538653d54fd1a41 100644 --- a/lms/templates/shoppingcart/receipt.html +++ b/lms/templates/shoppingcart/receipt.html @@ -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>