Stanford paid course registration
With tests, some settings changes (all should default to not breaking anything for edx) Added styling for shopping cart User Experience - Styled shoppingcart list page - Styled navigation shopping cart button - Styled receipt page - Styled course about page for shopping cart courses Addressed HTML/SCSS issues Remove offending body class and unnecessary sass changes Addresses many review comments on stanford shopping cart * framework for generating order instructions on receipt page in shoppingcart.models * move user_cart_has_item into shoppingcart.models * move min_course_price_for_currency into course_modes.models * remove auto activation on purchase * 2-space indents in templates * etc revert indentation on navigation.html for ease of review pep8 pylint move logging/error handling from shoppingcart view to model Addressing @dave changes
This commit is contained in:
@@ -37,7 +37,7 @@ class CourseMode(models.Model):
|
||||
currency = models.CharField(default="usd", max_length=8)
|
||||
|
||||
# turn this mode off after the given expiration date
|
||||
expiration_date = models.DateField(default=None, null=True)
|
||||
expiration_date = models.DateField(default=None, null=True, blank=True)
|
||||
|
||||
DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd')
|
||||
DEFAULT_MODE_SLUG = 'honor'
|
||||
@@ -86,6 +86,15 @@ class CourseMode(models.Model):
|
||||
else:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def min_course_price_for_currency(cls, course_id, currency):
|
||||
"""
|
||||
Returns the minimum price of the course in the appropriate currency over all the course's modes.
|
||||
If there is no mode found, will return the price of DEFAULT_MODE, which is 0
|
||||
"""
|
||||
modes = cls.modes_for_course(course_id)
|
||||
return min(mode.min_price for mode in modes if mode.currency == currency)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"{} : {}, min={}, prices={}".format(
|
||||
self.course_id, self.mode_slug, self.min_price, self.suggested_prices
|
||||
|
||||
@@ -73,6 +73,24 @@ class CourseModeModelTest(TestCase):
|
||||
self.assertEqual(mode2, CourseMode.mode_for_course(self.course_id, u'verified'))
|
||||
self.assertIsNone(CourseMode.mode_for_course(self.course_id, 'DNE'))
|
||||
|
||||
def test_min_course_price_for_currency(self):
|
||||
"""
|
||||
Get the min course price for a course according to currency
|
||||
"""
|
||||
# no modes, should get 0
|
||||
self.assertEqual(0, CourseMode.min_course_price_for_currency(self.course_id, 'usd'))
|
||||
|
||||
# create some modes
|
||||
mode1 = Mode(u'honor', u'Honor Code Certificate', 10, '', 'usd')
|
||||
mode2 = Mode(u'verified', u'Verified Certificate', 20, '', 'usd')
|
||||
mode3 = Mode(u'honor', u'Honor Code Certificate', 80, '', 'cny')
|
||||
set_modes = [mode1, mode2, mode3]
|
||||
for mode in set_modes:
|
||||
self.create_mode(mode.slug, mode.name, mode.min_price, mode.suggested_prices, mode.currency)
|
||||
|
||||
self.assertEqual(10, CourseMode.min_course_price_for_currency(self.course_id, 'usd'))
|
||||
self.assertEqual(80, CourseMode.min_course_price_for_currency(self.course_id, 'cny'))
|
||||
|
||||
def test_modes_for_course_expired(self):
|
||||
expired_mode, _status = self.create_mode('verified', 'Verified Certificate')
|
||||
expired_mode.expiration_date = datetime.now(pytz.UTC) + timedelta(days=-1)
|
||||
|
||||
@@ -11,20 +11,29 @@ import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.test.client import RequestFactory
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.hashers import UNUSABLE_PASSWORD
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.utils.http import int_to_base36
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE
|
||||
|
||||
from mock import Mock, patch
|
||||
from textwrap import dedent
|
||||
|
||||
from student.models import unique_id_for_user, CourseEnrollment
|
||||
from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper
|
||||
from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper,
|
||||
change_enrollment)
|
||||
from student.tests.factories import UserFactory
|
||||
from student.tests.test_email import mock_render_to_string
|
||||
|
||||
import shoppingcart
|
||||
|
||||
COURSE_1 = 'edX/toy/2012_Fall'
|
||||
COURSE_2 = 'edx/full/6.002_Spring_2012'
|
||||
|
||||
@@ -343,3 +352,32 @@ class EnrollInCourseTest(TestCase):
|
||||
# for that user/course_id combination
|
||||
CourseEnrollment.enroll(user, course_id)
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class PaidRegistrationTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for paid registration functionality (not verified student), involves shoppingcart
|
||||
"""
|
||||
# arbitrary constant
|
||||
COURSE_SLUG = "100"
|
||||
COURSE_NAME = "test_course"
|
||||
COURSE_ORG = "EDX"
|
||||
|
||||
def setUp(self):
|
||||
# Create course
|
||||
self.req_factory = RequestFactory()
|
||||
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
|
||||
self.assertIsNotNone(self.course)
|
||||
self.user = User.objects.create(username="jack", email="jack@fake.edx.org")
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('ENABLE_SHOPPING_CART'), "Shopping Cart not enabled in settings")
|
||||
def test_change_enrollment_add_to_cart(self):
|
||||
request = self.req_factory.post(reverse('change_enrollment'), {'course_id': self.course.id,
|
||||
'enrollment_action': 'add_to_cart'})
|
||||
request.user = self.user
|
||||
response = change_enrollment(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content, reverse('shoppingcart.views.show_cart'))
|
||||
self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order(
|
||||
shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id))
|
||||
|
||||
@@ -58,6 +58,7 @@ from external_auth.models import ExternalAuthMap
|
||||
import external_auth.views
|
||||
|
||||
from bulk_email.models import Optout
|
||||
import shoppingcart
|
||||
|
||||
import track.views
|
||||
|
||||
@@ -405,6 +406,19 @@ def change_enrollment(request):
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
elif action == "add_to_cart":
|
||||
# Pass the request handling to shoppingcart.views
|
||||
# The view in shoppingcart.views performs error handling and logs different errors. But this elif clause
|
||||
# is only used in the "auto-add after user reg/login" case, i.e. it's always wrapped in try_change_enrollment.
|
||||
# This means there's no good way to display error messages to the user. So we log the errors and send
|
||||
# the user to the shopping cart page always, where they can reasonably discern the status of their cart,
|
||||
# whether things got added, etc
|
||||
|
||||
shoppingcart.views.add_course_to_cart(request, course_id)
|
||||
return HttpResponse(
|
||||
reverse("shoppingcart.views.show_cart")
|
||||
)
|
||||
|
||||
elif action == "unenroll":
|
||||
try:
|
||||
CourseEnrollment.unenroll(user, course_id)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -85,7 +85,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
|
||||
self.login_user()
|
||||
resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id]))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id))
|
||||
self.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_id))
|
||||
|
||||
|
||||
@patch('shoppingcart.views.render_purchase_form_html', form_mock)
|
||||
|
||||
@@ -6,30 +6,33 @@ from django.views.decorators.http import require_POST
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from student.models import CourseEnrollment
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from .models import Order, PaidCourseRegistration, CertificateItem, OrderItem
|
||||
from .models import Order, PaidCourseRegistration, OrderItem
|
||||
from .processors import process_postpay_callback, render_purchase_form_html
|
||||
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException
|
||||
|
||||
log = logging.getLogger("shoppingcart")
|
||||
|
||||
|
||||
@require_POST
|
||||
def add_course_to_cart(request, course_id):
|
||||
"""
|
||||
Adds course specified by course_id to the cart. The model function add_to_order does all the
|
||||
heavy lifting (logging, error checking, etc)
|
||||
"""
|
||||
if not request.user.is_authenticated():
|
||||
log.info("Anon user trying to add course {} to cart".format(course_id))
|
||||
return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart'))
|
||||
cart = Order.get_cart_for_user(request.user)
|
||||
if PaidCourseRegistration.part_of_order(cart, course_id):
|
||||
return HttpResponseBadRequest(_('The course {0} is already in your cart.'.format(course_id)))
|
||||
if CourseEnrollment.is_enrolled(user=request.user, course_id=course_id):
|
||||
return HttpResponseBadRequest(_('You are already registered in course {0}.'.format(course_id)))
|
||||
|
||||
# All logging from here handled by the model
|
||||
try:
|
||||
PaidCourseRegistration.add_to_order(cart, course_id)
|
||||
except ItemNotFoundError:
|
||||
except CourseDoesNotExistException:
|
||||
return HttpResponseNotFound(_('The course you requested does not exist.'))
|
||||
if request.method == 'GET': # This is temporary for testing purposes and will go away before we pull
|
||||
return HttpResponseRedirect(reverse('shoppingcart.views.show_cart'))
|
||||
except ItemAlreadyInCartException:
|
||||
return HttpResponseBadRequest(_('The course {0} is already in your cart.'.format(course_id)))
|
||||
except AlreadyEnrolledInCourseException:
|
||||
return HttpResponseBadRequest(_('You are already registered in course {0}.'.format(course_id)))
|
||||
return HttpResponse(_("Course added to cart."))
|
||||
|
||||
|
||||
@@ -103,12 +106,14 @@ def show_receipt(request, ordernum):
|
||||
order_items = OrderItem.objects.filter(order=order).select_subclasses()
|
||||
any_refunds = any(i.status == "refunded" for i in order_items)
|
||||
receipt_template = 'shoppingcart/receipt.html'
|
||||
__, instructions = order.generate_receipt_instructions()
|
||||
# we want to have the ability to override the default receipt page when
|
||||
# there is only one item in the order
|
||||
context = {
|
||||
'order': order,
|
||||
'order_items': order_items,
|
||||
'any_refunds': any_refunds,
|
||||
'instructions': instructions,
|
||||
}
|
||||
|
||||
if order_items.count() == 1:
|
||||
|
||||
@@ -100,6 +100,8 @@ with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
|
||||
ENV_TOKENS = json.load(env_file)
|
||||
|
||||
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME)
|
||||
# For displaying on the receipt. At Stanford PLATFORM_NAME != MERCHANT_NAME, but PLATFORM_NAME is a fine default
|
||||
CC_MERCHANT_NAME = ENV_TOKENS.get('CC_MERCHANT_NAME', PLATFORM_NAME)
|
||||
EMAIL_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND)
|
||||
EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None)
|
||||
EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost
|
||||
@@ -136,6 +138,8 @@ TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
|
||||
CONTACT_EMAIL = ENV_TOKENS.get('CONTACT_EMAIL', CONTACT_EMAIL)
|
||||
BUGS_EMAIL = ENV_TOKENS.get('BUGS_EMAIL', BUGS_EMAIL)
|
||||
PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_EMAIL)
|
||||
PAID_COURSE_REGISTRATION_CURRENCY = ENV_TOKENS.get('PAID_COURSE_REGISTRATION_CURRENCY',
|
||||
PAID_COURSE_REGISTRATION_CURRENCY)
|
||||
|
||||
#Theme overrides
|
||||
THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
|
||||
|
||||
@@ -36,6 +36,7 @@ from xmodule.modulestore.inheritance import InheritanceMixin
|
||||
################################### FEATURES ###################################
|
||||
# The display name of the platform to be used in templates/emails/etc.
|
||||
PLATFORM_NAME = "edX"
|
||||
CC_MERCHANT_NAME = PLATFORM_NAME
|
||||
|
||||
COURSEWARE_ENABLED = True
|
||||
ENABLE_JASMINE = False
|
||||
@@ -171,6 +172,9 @@ MITX_FEATURES = {
|
||||
|
||||
# Toggle storing detailed billing information
|
||||
'STORE_BILLING_INFO': False,
|
||||
|
||||
# Enable flow for payments for course registration (DIFFERENT from verified student flow)
|
||||
'ENABLE_PAID_COURSE_REGISTRATION': False,
|
||||
}
|
||||
|
||||
# Used for A/B testing
|
||||
@@ -500,7 +504,8 @@ CC_PROCESSOR = {
|
||||
'PURCHASE_ENDPOINT': '',
|
||||
}
|
||||
}
|
||||
|
||||
# Setting for PAID_COURSE_REGISTRATION, DOES NOT AFFECT VERIFIED STUDENTS
|
||||
PAID_COURSE_REGISTRATION_CURRENCY = ['usd', '$']
|
||||
################################# open ended grading config #####################
|
||||
|
||||
#By setting up the default settings with an incorrect user name and password,
|
||||
|
||||
@@ -165,7 +165,6 @@ OPENID_PROVIDER_TRUSTED_ROOTS = ['*']
|
||||
###################### Payment ##############################3
|
||||
# Enable fake payment processing page
|
||||
MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
|
||||
|
||||
# Configure the payment processor to use the fake processing page
|
||||
# Since both the fake payment page and the shoppingcart app are using
|
||||
# the same settings, we can generate this randomly and guarantee
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
|
||||
// base - specific views
|
||||
@import 'views/verification';
|
||||
@import 'views/shoppingcart';
|
||||
|
||||
// shared - course
|
||||
@import 'shared/forms';
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
@include transition(all 0.15s linear 0s);
|
||||
width: flex-grid(12);
|
||||
|
||||
> a.find-courses, a.register {
|
||||
> a.find-courses, a.register, a.add-to-cart {
|
||||
@include button(shiny, $button-color);
|
||||
@include box-sizing(border-box);
|
||||
border-radius: 3px;
|
||||
@@ -139,7 +139,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
span.register {
|
||||
span.register, span.add-to-cart {
|
||||
background: $button-archive-color;
|
||||
border: 1px solid darken($button-archive-color, 50%);
|
||||
@include box-sizing(border-box);
|
||||
|
||||
@@ -123,6 +123,13 @@ header.global {
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-left: none;
|
||||
padding: 5px 8px 7px 8px;
|
||||
|
||||
&.shopping-cart {
|
||||
border-radius: 4px;
|
||||
border: 1px solid $border-color-2;
|
||||
margin-right: 10px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
103
lms/static/sass/views/_shoppingcart.scss
Normal file
103
lms/static/sass/views/_shoppingcart.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,8 @@ from django.utils.translation import ugettext as _
|
||||
import branding
|
||||
# app that handles site status messages
|
||||
from status.status import get_site_status_msg
|
||||
# shopping cart
|
||||
import shoppingcart
|
||||
%>
|
||||
|
||||
## Provide a hook for themes to inject branding on top.
|
||||
@@ -79,7 +81,17 @@ site_status_msg = get_site_status_msg(course_id)
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
% if settings.MITX_FEATURES.get('ENABLE_PAID_COURSE_REGISTRATION') and \
|
||||
settings.MITX_FEATURES['ENABLE_SHOPPING_CART'] and \
|
||||
shoppingcart.models.Order.user_cart_has_items(user):
|
||||
<ol class="user">
|
||||
<li class="primary">
|
||||
<a class="shopping-cart" href="${reverse('shoppingcart.views.show_cart')}">
|
||||
<i class="icon-shopping-cart"></i> Shopping Cart
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
% endif
|
||||
% else:
|
||||
<ol class="left nav-global">
|
||||
<%block name="navigation_global_links">
|
||||
|
||||
@@ -7,47 +7,62 @@
|
||||
<%block name="title"><title>${_("Your Shopping Cart")}</title></%block>
|
||||
|
||||
<section class="container cart-list">
|
||||
<h2>${_("Your selected items:")}</h2>
|
||||
% if shoppingcart_items:
|
||||
<table>
|
||||
<thead>
|
||||
<tr>${_("<td>Quantity</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
% for item in shoppingcart_items:
|
||||
<tr><td>${item.qty}</td><td>${item.line_desc}</td>
|
||||
<td>${"{0:0.2f}".format(item.unit_cost)}</td><td>${"{0:0.2f}".format(item.line_cost)}</td>
|
||||
<td>${item.currency.upper()}</td>
|
||||
<td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td></tr>
|
||||
% endfor
|
||||
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr>
|
||||
<tr><td></td><td></td><td></td><td>${"{0:0.2f}".format(amount)}</td></tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- <input id="back_input" type="submit" value="Return" /> -->
|
||||
${form_html}
|
||||
% else:
|
||||
<p>${_("You have selected no items for purchase.")}</p>
|
||||
% endif
|
||||
<h2>${_("Your selected items:")}</h2>
|
||||
% if shoppingcart_items:
|
||||
<table class="cart-table">
|
||||
<thead>
|
||||
<tr class="cart-headings">
|
||||
<th class="qty">${_("Quantity")}</th>
|
||||
<th class="dsc">${_("Description")}</th>
|
||||
<th class="u-pr">${_("Unit Price")}</th>
|
||||
<th class="prc">${_("Price")}</th>
|
||||
<th class="cur">${_("Currency")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
% for item in shoppingcart_items:
|
||||
<tr class="cart-items">
|
||||
<td>${item.qty}</td>
|
||||
<td>${item.line_desc}</td>
|
||||
<td>${"{0:0.2f}".format(item.unit_cost)}</td>
|
||||
<td>${"{0:0.2f}".format(item.line_cost)}</td>
|
||||
<td>${item.currency.upper()}</td>
|
||||
<td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td>
|
||||
</tr>
|
||||
% endfor
|
||||
<tr class="cart-headings">
|
||||
<td colspan="4"></td>
|
||||
<th>${_("Total Amount")}</th>
|
||||
</tr>
|
||||
<tr class="cart-totals">
|
||||
<td colspan="4"></td>
|
||||
<td class="cart-total-cost">${"{0:0.2f}".format(amount)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- <input id="back_input" type="submit" value="Return" /> -->
|
||||
${form_html}
|
||||
% else:
|
||||
<p>${_("You have selected no items for purchase.")}</p>
|
||||
% endif
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
$('a.remove_line_item').click(function(event) {
|
||||
event.preventDefault();
|
||||
var post_url = "${reverse('shoppingcart.views.remove_item')}";
|
||||
$.post(post_url, {id:$(this).data('item-id')})
|
||||
.always(function(data){
|
||||
location.reload(true);
|
||||
});
|
||||
});
|
||||
|
||||
$('#back_input').click(function(){
|
||||
history.back();
|
||||
});
|
||||
$(function() {
|
||||
$('a.remove_line_item').click(function(event) {
|
||||
event.preventDefault();
|
||||
var post_url = "${reverse('shoppingcart.views.remove_item')}";
|
||||
$.post(post_url, {id:$(this).data('item-id')})
|
||||
.always(function(data){
|
||||
location.reload(true);
|
||||
});
|
||||
});
|
||||
|
||||
$('#back_input').click(function(){
|
||||
history.back();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,70 +3,89 @@
|
||||
<%! from django.conf import settings %>
|
||||
|
||||
<%inherit file="../main.html" />
|
||||
<%block name="bodyclass">register verification-process step-requirements</%block>
|
||||
<%block name="bodyclass">purchase-receipt</%block>
|
||||
|
||||
<%block name="title"><title>${_("Register for [Course Name] | Receipt (Order")} ${order.id})</title></%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
% if notification is not UNDEFINED:
|
||||
<section class="notification">
|
||||
${notification}
|
||||
</section>
|
||||
% endif
|
||||
|
||||
<div class="container">
|
||||
<section class="wrapper cart-list">
|
||||
<section class="notification">
|
||||
<h2>Thank you for your Purchase!</h2>
|
||||
<p>Please print this receipt page for your records. You should also have received a receipt in your email.</p>
|
||||
% for inst in instructions:
|
||||
<p>${inst}</p>
|
||||
% endfor
|
||||
</section>
|
||||
|
||||
<section class="wrapper cart-list">
|
||||
<div class="wrapper-content-main">
|
||||
<article class="content-main">
|
||||
<h3 class="title">${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}</h3>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user