Commit c1d555be by Jason Bau

Merge pull request #1118 from edx/jbau/shoppingcart-stanford

Stanford paid course registration
parents 9385b576 667a1ead
...@@ -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
...@@ -2,7 +2,10 @@ from datetime import datetime ...@@ -2,7 +2,10 @@ from datetime import datetime
import pytz import pytz
import logging import logging
import smtplib 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.db import models
from django.conf import settings from django.conf import settings
...@@ -11,19 +14,20 @@ from django.core.mail import send_mail ...@@ -11,19 +14,20 @@ from django.core.mail import send_mail
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.db import transaction 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 course_modes.models import CourseMode
from courseware.courses import get_course_about_section
from mitxmako.shortcuts import render_to_string from mitxmako.shortcuts import render_to_string
from student.views import course_from_id from student.views import course_from_id
from student.models import CourseEnrollment from student.models import CourseEnrollment
from dogapi import dog_stats_api
from verify_student.models import SoftwareSecurePhotoVerification 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") log = logging.getLogger("shoppingcart")
...@@ -33,6 +37,9 @@ ORDER_STATUSES = ( ...@@ -33,6 +37,9 @@ ORDER_STATUSES = (
('refunded', 'refunded'), # Not used for now ('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): class Order(models.Model):
""" """
...@@ -72,13 +79,30 @@ class Order(models.Model): ...@@ -72,13 +79,30 @@ class Order(models.Model):
cart_order, _created = cls.objects.get_or_create(user=user, status='cart') cart_order, _created = cls.objects.get_or_create(user=user, status='cart')
return cart_order 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 @property
def total_cost(self): def total_cost(self):
""" """
Return the total cost of the cart. If the order has been purchased, returns total of Return the total cost of the cart. If the order has been purchased, returns total of
all purchased and not refunded items. 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): def clear(self):
""" """
...@@ -135,13 +159,31 @@ class Order(models.Model): ...@@ -135,13 +159,31 @@ class Order(models.Model):
subject = _("Order Payment Confirmation") subject = _("Order Payment Confirmation")
message = render_to_string('emails/order_confirmation_email.txt', { message = render_to_string('emails/order_confirmation_email.txt', {
'order': self, 'order': self,
'order_items': orderitems 'order_items': orderitems,
'has_billing_info': settings.MITX_FEATURES['STORE_BILLING_INFO']
}) })
try: try:
send_mail(subject, message, send_mail(subject, message,
settings.DEFAULT_FROM_EMAIL, [self.user.email]) settings.DEFAULT_FROM_EMAIL, [self.user.email]) # pylint: disable=E1101
except smtplib.SMTPException: except (smtplib.SMTPException, BotoServerError): # sadly need to handle diff. mail backends individually
log.error('Failed sending confirmation e-mail for order %d', self.id) 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): class OrderItem(models.Model):
...@@ -202,6 +244,22 @@ class OrderItem(models.Model): ...@@ -202,6 +244,22 @@ class OrderItem(models.Model):
""" """
raise NotImplementedError 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 @property
def single_item_receipt_template(self): def single_item_receipt_template(self):
""" """
...@@ -235,9 +293,9 @@ class PaidCourseRegistration(OrderItem): ...@@ -235,9 +293,9 @@ class PaidCourseRegistration(OrderItem):
mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG) mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG)
@classmethod @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 return course_id in [item.paidcourseregistration.course_id
for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")] for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")]
...@@ -251,10 +309,26 @@ class PaidCourseRegistration(OrderItem): ...@@ -251,10 +309,26 @@ class PaidCourseRegistration(OrderItem):
Returns the order item Returns the order item
""" """
# TODO: Possibly add checking for whether student is already enrolled in course # 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 course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to
# throw errors if it doesn't # 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 ### handle default arguments for mode_slug, cost, currency
course_mode = CourseMode.mode_for_course(course_id, mode_slug) course_mode = CourseMode.mode_for_course(course_id, mode_slug)
if not course_mode: if not course_mode:
...@@ -273,12 +347,13 @@ class PaidCourseRegistration(OrderItem): ...@@ -273,12 +347,13 @@ class PaidCourseRegistration(OrderItem):
item.mode = course_mode.slug item.mode = course_mode.slug
item.qty = 1 item.qty = 1
item.unit_cost = cost item.unit_cost = cost
item.line_desc = 'Registration for Course: {0}. Mode: {1}'.format(get_course_about_section(course, "title"), item.line_desc = 'Registration for Course: {0}'.format(course.display_name_with_default)
course_mode.name)
item.currency = currency item.currency = currency
order.currency = currency order.currency = currency
order.save() order.save()
item.save() item.save()
log.info("User {} added course registration {} to cart: order {}"
.format(order.user.email, course_id, order.id))
return item return item
def purchased_callback(self): def purchased_callback(self):
...@@ -301,14 +376,18 @@ class PaidCourseRegistration(OrderItem): ...@@ -301,14 +376,18 @@ class PaidCourseRegistration(OrderItem):
CourseEnrollment.enroll(user=self.user, course_id=self.course_id, mode=self.mode) 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)) log.info("Enrolled {0} in paid course {1}, paid ${2}"
org, course_num, run = self.course_id.split("/") .format(self.user.email, self.course_id, self.line_cost)) # pylint: disable=E1101
dog_stats_api.increment(
"shoppingcart.PaidCourseRegistration.purchased_callback.enrollment", def generate_receipt_instructions(self):
tags=["org:{0}".format(org), """
"course:{0}".format(course_num), Generates instructions when the user has purchased a PaidCourseRegistration.
"run:{0}".format(run)] 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): class CertificateItem(OrderItem):
......
""" """
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:
...@@ -92,14 +117,37 @@ ...@@ -92,14 +117,37 @@
<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">
${_("You are registered for this course {course.display_number_with_default}").format(course=course) | h}
</span>
%if show_courseware_link: %if show_courseware_link:
<strong>${_("View Courseware")}</strong> <strong>${_("View Courseware")}</strong>
</a> </a>
%endif %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">
${_("Register for {course.display_number_with_default}").format(course=course) | h}
</a>
<div id="register_error"></div> <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">
......
...@@ -9,20 +9,35 @@ ...@@ -9,20 +9,35 @@
<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">
<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> </thead>
<tbody> <tbody>
% for item in shoppingcart_items: % for item in shoppingcart_items:
<tr><td>${item.qty}</td><td>${item.line_desc}</td> <tr class="cart-items">
<td>${"{0:0.2f}".format(item.unit_cost)}</td><td>${"{0:0.2f}".format(item.line_cost)}</td> <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>${item.currency.upper()}</td>
<td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td></tr> <td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td>
</tr>
% endfor % endfor
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr> <tr class="cart-headings">
<tr><td></td><td></td><td></td><td>${"{0:0.2f}".format(amount)}</td></tr> <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> </tbody>
</table> </table>
<!-- <input id="back_input" type="submit" value="Return" /> --> <!-- <input id="back_input" type="submit" value="Return" /> -->
......
...@@ -3,54 +3,73 @@ ...@@ -3,54 +3,73 @@
<%! 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>
<tr>${_("<td>Qty</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr>
</thead>
<tbody> <tbody>
% for item in order_items:
<tr> <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": % if item.status == "purchased":
<td>${item.qty}</td><td>${item.line_desc}</td> <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>
<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> </tbody>
</table> </table>
% if any_refunds: % if any_refunds:
<p> <p>
${_("Note: items with strikethough like ")}<del>this</del>${_(" have been refunded.")} ${_("Note: items with strikethough like ")}<del>this</del>${_(" have been refunded.")}
...@@ -66,7 +85,7 @@ ...@@ -66,7 +85,7 @@
${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}<br /> ${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}<br />
${order.bill_to_country.upper()}<br /> ${order.bill_to_country.upper()}<br />
</p> </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