Commit 665ccdb7 by chrisndodge

Merge pull request #4172 from edx/cdodge/ecommerce-improvements

eCommerce enhancements
parents 256c6bb4 5734d2a8
......@@ -158,3 +158,5 @@ Tim Babych <tim.babych@gmail.com>
Brandon DeRosier <btd@cheesekeg.com>
Daniel Li <swli@edx.org>
Daniel Friedman <dfriedman@edx.org>
Asad Iqbal <aiqbal@edx.org>
Muhammad Shoaib <mshoaib@edx.org>
......@@ -3,7 +3,7 @@ django admin pages for courseware model
'''
from student.models import UserProfile, UserTestGroup, CourseEnrollmentAllowed
from student.models import CourseEnrollment, Registration, PendingNameChange
from student.models import CourseEnrollment, Registration, PendingNameChange, CourseAccessRole
from ratelimitbackend import admin
admin.site.register(UserProfile)
......@@ -17,3 +17,5 @@ admin.site.register(CourseEnrollmentAllowed)
admin.site.register(Registration)
admin.site.register(PendingNameChange)
admin.site.register(CourseAccessRole)
......@@ -271,6 +271,15 @@ class UserProfile(models.Model):
self.save()
class UserSignupSource(models.Model):
"""
This table contains information about users registering
via Micro-Sites
"""
user_id = models.ForeignKey(User, db_index=True)
site = models.CharField(max_length=255, db_index=True)
def unique_id_for_user(user, save=True):
"""
Return a unique id for a user, suitable for inserting into
......@@ -1035,6 +1044,9 @@ class CourseAccessRole(models.Model):
"""
return self._key < other._key
def __unicode__(self):
return "[CourseAccessRole] user: {} role: {} org: {} course: {}".format(self.user.username, self.role, self.org, self.course_id)
#### Helper methods for use from python manage.py shell and other classes.
......
......@@ -201,6 +201,13 @@ class CourseInstructorRole(CourseRole):
super(CourseInstructorRole, self).__init__(self.ROLE, *args, **kwargs)
class CourseFinanceAdminRole(CourseRole):
"""A course Instructor"""
ROLE = 'finance_admin'
def __init__(self, *args, **kwargs):
super(CourseFinanceAdminRole, self).__init__(self.ROLE, *args, **kwargs)
class CourseBetaTesterRole(CourseRole):
"""A course Beta Tester"""
ROLE = 'beta_testers'
......
"""
Test for User Creation from Micro-Sites
"""
from django.test import TestCase
from student.models import UserSignupSource
import mock
from django.core.urlresolvers import reverse
def fake_site_name(name, default=None): # pylint: disable=W0613
"""
create a fake microsite site name
"""
if name == 'SITE_NAME':
return 'openedx.localhost'
else:
return None
class TestMicrosite(TestCase):
"""Test for Account Creation from a white labeled Micro-Sites"""
def setUp(self):
self.username = "test_user"
self.url = reverse("create_account")
self.params = {
"username": self.username,
"email": "test@example.org",
"password": "testpass",
"name": "Test User",
"honor_code": "true",
"terms_of_service": "true",
}
@mock.patch("microsite_configuration.microsite.get_value", fake_site_name)
def test_user_signup_source(self):
"""
test to create a user form the microsite and see that it record has been
saved in the UserSignupSource Table
"""
response = self.client.post(self.url, self.params)
self.assertEqual(response.status_code, 200)
self.assertGreater(len(UserSignupSource.objects.filter(site='openedx.localhost')), 0)
def test_user_signup_from_non_micro_site(self):
"""
test to create a user form the non-microsite. The record should not be saved
in the UserSignupSource Table
"""
response = self.client.post(self.url, self.params)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(UserSignupSource.objects.filter(site='openedx.localhost')), 0)
......@@ -30,6 +30,9 @@ from django.utils.translation import ugettext as _, get_language
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_POST, require_GET
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.template.response import TemplateResponse
from ratelimitbackend.exceptions import RateLimitException
......@@ -42,7 +45,7 @@ from student.models import (
Registration, UserProfile, PendingNameChange,
PendingEmailChange, CourseEnrollment, unique_id_for_user,
CourseEnrollmentAllowed, UserStanding, LoginFailures,
create_comments_service_user, PasswordHistory
create_comments_service_user, PasswordHistory, UserSignupSource
)
from student.forms import PasswordResetFormNoActive
......@@ -1021,6 +1024,21 @@ class AccountValidationError(Exception):
super(AccountValidationError, self).__init__(message)
self.field = field
@receiver(post_save, sender=User)
def user_signup_handler(sender, **kwargs): # pylint: disable=W0613
"""
handler that saves the user Signup Source
when the user is created
"""
if 'created' in kwargs and kwargs['created']:
site = microsite.get_value('SITE_NAME')
if site:
user_signup_source = UserSignupSource(user_id=kwargs['instance'], site=site)
user_signup_source.save()
log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id))
def _do_create_account(post_vars):
"""
Given cleaned post variables, create the User and UserProfile objects, as well as the
......
"""
Unit tests for Ecommerce feature flag in new instructor dashboard.
"""
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from student.tests.factories import AdminFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.models import CourseMode
from shoppingcart.models import Coupon, PaidCourseRegistration
from mock import patch
from student.roles import CourseFinanceAdminRole
# pylint: disable=E1101
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestECommerceDashboardViews(ModuleStoreTestCase):
"""
Check for email view on the new instructor dashboard
for Mongo-backed courses
"""
def setUp(self):
self.course = CourseFactory.create()
# Create instructor account
self.instructor = AdminFactory.create()
self.client.login(username=self.instructor.username, password="test")
mode = CourseMode(
course_id=self.course.id.to_deprecated_string(), mode_slug='honor',
mode_display_name='honor', min_price=10, currency='usd'
)
mode.save()
# URL for instructor dash
self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()})
self.e_commerce_link = '<a href="" data-section="e-commerce">E-Commerce</a>'
CourseFinanceAdminRole(self.course.id).add_users(self.instructor)
def tearDown(self):
"""
Undo all patches.
"""
patch.stopall()
def test_pass_e_commerce_tab_in_instructor_dashboard(self):
"""
Test Pass E-commerce Tab is in the Instructor Dashboard
"""
response = self.client.get(self.url)
self.assertTrue(self.e_commerce_link in response.content)
def test_user_has_finance_admin_rights_in_e_commerce_tab(self):
response = self.client.get(self.url)
self.assertTrue(self.e_commerce_link in response.content)
# Total amount html should render in e-commerce page, total amount will be 0
total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course.id)
self.assertTrue('<span>Total Amount: <span>$' + str(total_amount) + '</span></span>' in response.content)
# removing the course finance_admin role of login user
CourseFinanceAdminRole(self.course.id).remove_users(self.instructor)
# total amount should not be visible in e-commerce page if the user is not finance admin
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()})
response = self.client.post(url)
total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course.id)
self.assertFalse('<span>Total Amount: <span>$' + str(total_amount) + '</span></span>' in response.content)
def test_add_coupon(self):
"""
Test Add Coupon Scenarios. Handle all the HttpResponses return by add_coupon view
"""
# URL for add_coupon
add_coupon_url = reverse('add_coupon', kwargs={'course_id': self.course.id.to_deprecated_string()})
data = {
'code': 'A2314', 'course_id': self.course.id.to_deprecated_string(),
'description': 'ADSADASDSAD', 'created_by': self.instructor, 'discount': 5
}
response = self.client.post(add_coupon_url, data)
self.assertTrue("coupon with the coupon code ({code}) added successfully".format(code=data['code']) in response.content)
data = {
'code': 'A2314', 'course_id': self.course.id.to_deprecated_string(),
'description': 'asdsasda', 'created_by': self.instructor, 'discount': 111
}
response = self.client.post(add_coupon_url, data)
self.assertTrue("coupon with the coupon code ({code}) already exist".format(code='A2314') in response.content)
response = self.client.post(self.url)
self.assertTrue('<td>ADSADASDSAD</td>' in response.content)
self.assertTrue('<td>A2314</td>' in response.content)
self.assertFalse('<td>111</td>' in response.content)
def test_delete_coupon(self):
"""
Test Delete Coupon Scenarios. Handle all the HttpResponses return by remove_coupon view
"""
coupon = Coupon(
code='AS452', description='asdsadsa', course_id=self.course.id.to_deprecated_string(),
percentage_discount=10, created_by=self.instructor
)
coupon.save()
response = self.client.post(self.url)
self.assertTrue('<td>AS452</td>' in response.content)
# URL for remove_coupon
delete_coupon_url = reverse('remove_coupon', kwargs={'course_id': self.course.id.to_deprecated_string()})
response = self.client.post(delete_coupon_url, {'id': coupon.id})
self.assertTrue('coupon with the coupon id ({coupon_id}) updated successfully'.format(coupon_id=coupon.id) in response.content)
coupon.is_active = False
coupon.save()
response = self.client.post(delete_coupon_url, {'id': coupon.id})
self.assertTrue('coupon with the coupon id ({coupon_id}) is already inactive'.format(coupon_id=coupon.id) in response.content)
response = self.client.post(delete_coupon_url, {'id': 24454})
self.assertTrue('coupon with the coupon id ({coupon_id}) DoesNotExist'.format(coupon_id=24454) in response.content)
response = self.client.post(delete_coupon_url, {'id': ''})
self.assertTrue('coupon id is None' in response.content)
def test_get_coupon_info(self):
"""
Test Edit Coupon Info Scenarios. Handle all the HttpResponses return by edit_coupon_info view
"""
coupon = Coupon(
code='AS452', description='asdsadsa', course_id=self.course.id.to_deprecated_string(),
percentage_discount=10, created_by=self.instructor
)
coupon.save()
# URL for edit_coupon_info
edit_url = reverse('get_coupon_info', kwargs={'course_id': self.course.id.to_deprecated_string()})
response = self.client.post(edit_url, {'id': coupon.id})
self.assertTrue('coupon with the coupon id ({coupon_id}) updated successfully'.format(coupon_id=coupon.id) in response.content)
response = self.client.post(edit_url, {'id': 444444})
self.assertTrue('coupon with the coupon id ({coupon_id}) DoesNotExist'.format(coupon_id=444444) in response.content)
response = self.client.post(edit_url, {'id': ''})
self.assertTrue('coupon id not found"' in response.content)
coupon.is_active = False
coupon.save()
response = self.client.post(edit_url, {'id': coupon.id})
self.assertTrue("coupon with the coupon id ({coupon_id}) is already inactive".format(coupon_id=coupon.id) in response.content)
def test_update_coupon(self):
"""
Test Update Coupon Info Scenarios. Handle all the HttpResponses return by update_coupon view
"""
coupon = Coupon(
code='AS452', description='asdsadsa', course_id=self.course.id.to_deprecated_string(),
percentage_discount=10, created_by=self.instructor
)
coupon.save()
response = self.client.post(self.url)
self.assertTrue('<td>AS452</td>' in response.content)
data = {
'coupon_id': coupon.id, 'code': 'update_code', 'discount': '12',
'course_id': coupon.course_id.to_deprecated_string()
}
# URL for update_coupon
update_coupon_url = reverse('update_coupon', kwargs={'course_id': self.course.id.to_deprecated_string()})
response = self.client.post(update_coupon_url, data=data)
self.assertTrue('coupon with the coupon id ({coupon_id}) updated Successfully'.format(coupon_id=coupon.id)in response.content)
response = self.client.post(self.url)
self.assertTrue('<td>update_code</td>' in response.content)
self.assertTrue('<td>12</td>' in response.content)
data['coupon_id'] = 1000 # Coupon Not Exist with this ID
response = self.client.post(update_coupon_url, data=data)
self.assertTrue('coupon with the coupon id ({coupon_id}) DoesNotExist'.format(coupon_id=1000) in response.content)
data['coupon_id'] = '' # Coupon id is not provided
response = self.client.post(update_coupon_url, data=data)
self.assertTrue('coupon id not found' in response.content)
coupon1 = Coupon(
code='11111', description='coupon', course_id=self.course.id.to_deprecated_string(),
percentage_discount=20, created_by=self.instructor
)
coupon1.save()
data = {'coupon_id': coupon.id, 'code': '11111', 'discount': '12'}
response = self.client.post(update_coupon_url, data=data)
self.assertTrue('coupon with the coupon id ({coupon_id}) already exist'.format(coupon_id=coupon.id) in response.content)
"""
E-commerce Tab Instructor Dashboard Coupons Operations views
"""
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from django.views.decorators.http import require_POST
from django.utils.translation import ugettext as _
from util.json_request import JsonResponse
from django.http import HttpResponse, HttpResponseNotFound
from shoppingcart.models import Coupon
import logging
log = logging.getLogger(__name__)
@require_POST
@login_required
def remove_coupon(request, course_id): # pylint: disable=W0613
"""
remove the coupon against the coupon id
set the coupon is_active flag to false
"""
coupon_id = request.POST.get('id', None)
if not coupon_id:
return JsonResponse({
'message': _('coupon id is None')
}, status=400) # status code 400: Bad Request
try:
coupon = Coupon.objects.get(id=coupon_id)
except ObjectDoesNotExist:
return JsonResponse({
'message': _('coupon with the coupon id ({coupon_id}) DoesNotExist').format(coupon_id=coupon_id)
}, status=400) # status code 400: Bad Request
if not coupon.is_active:
return JsonResponse({
'message': _('coupon with the coupon id ({coupon_id}) is already inactive').format(coupon_id=coupon_id)
}, status=400) # status code 400: Bad Request
coupon.is_active = False
coupon.save()
return JsonResponse({
'message': _('coupon with the coupon id ({coupon_id}) updated successfully').format(coupon_id=coupon_id)
}) # status code 200: OK by default
@require_POST
@login_required
def add_coupon(request, course_id): # pylint: disable=W0613
"""
add coupon in the Coupons Table
"""
code = request.POST.get('code')
# check if the code is already in the Coupons Table and active
coupon = Coupon.objects.filter(is_active=True, code=code)
if coupon:
return HttpResponseNotFound(_("coupon with the coupon code ({code}) already exist").format(code=code))
description = request.POST.get('description')
course_id = request.POST.get('course_id')
discount = request.POST.get('discount')
coupon = Coupon(
code=code, description=description, course_id=course_id,
percentage_discount=discount, created_by_id=request.user.id
)
coupon.save()
return HttpResponse(_("coupon with the coupon code ({code}) added successfully").format(code=code))
@require_POST
@login_required
def update_coupon(request, course_id): # pylint: disable=W0613
"""
update the coupon object in the database
"""
coupon_id = request.POST.get('coupon_id', None)
if not coupon_id:
return HttpResponseNotFound(_("coupon id not found"))
try:
coupon = Coupon.objects.get(pk=coupon_id)
except ObjectDoesNotExist:
return HttpResponseNotFound(_("coupon with the coupon id ({coupon_id}) DoesNotExist").format(coupon_id=coupon_id))
code = request.POST.get('code')
filtered_coupons = Coupon.objects.filter(~Q(id=coupon_id), code=code, is_active=True)
if filtered_coupons:
return HttpResponseNotFound(_("coupon with the coupon id ({coupon_id}) already exists").format(coupon_id=coupon_id))
description = request.POST.get('description')
course_id = request.POST.get('course_id')
discount = request.POST.get('discount')
coupon.code = code
coupon.description = description
coupon.course_id = course_id
coupon.percentage_discount = discount
coupon.save()
return HttpResponse(_("coupon with the coupon id ({coupon_id}) updated Successfully").format(coupon_id=coupon_id))
@require_POST
@login_required
def get_coupon_info(request, course_id): # pylint: disable=W0613
"""
get the coupon information to display in the pop up form
"""
coupon_id = request.POST.get('id', None)
if not coupon_id:
return JsonResponse({
'message': _("coupon id not found")
}, status=400) # status code 400: Bad Request
try:
coupon = Coupon.objects.get(id=coupon_id)
except ObjectDoesNotExist:
return JsonResponse({
'message': _("coupon with the coupon id ({coupon_id}) DoesNotExist").format(coupon_id=coupon_id)
}, status=400) # status code 400: Bad Request
if not coupon.is_active:
return JsonResponse({
'message': _("coupon with the coupon id ({coupon_id}) is already inactive").format(coupon_id=coupon_id)
}, status=400) # status code 400: Bad Request
return JsonResponse({
'coupon_code': coupon.code,
'coupon_description': coupon.description,
'coupon_course_id': coupon.course_id.to_deprecated_string(),
'coupon_discount': coupon.percentage_discount,
'message': _('coupon with the coupon id ({coupon_id}) updated successfully').format(coupon_id=coupon_id)
}) # status code 200: OK by default
......@@ -26,6 +26,10 @@ from courseware.courses import get_course_by_id, get_cms_course_link, get_course
from django_comment_client.utils import has_forum_access
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from student.models import CourseEnrollment
from shoppingcart.models import Coupon, PaidCourseRegistration
from course_modes.models import CourseMode
from student.roles import CourseFinanceAdminRole
from bulk_email.models import CourseAuthorization
from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem
......@@ -49,6 +53,7 @@ def instructor_dashboard_2(request, course_id):
access = {
'admin': request.user.is_staff,
'instructor': has_access(request.user, 'instructor', course),
'finance_admin': CourseFinanceAdminRole(course_key).has_user(request.user),
'staff': has_access(request.user, 'staff', course),
'forum_admin': has_forum_access(
request.user, course_key, FORUM_ROLE_ADMINISTRATOR
......@@ -66,6 +71,12 @@ def instructor_dashboard_2(request, course_id):
_section_analytics(course_key, access),
]
#check if there is corresponding entry in the CourseMode Table related to the Instructor Dashboard course
course_honor_mode = CourseMode.mode_for_course(course_key, 'honor')
course_mode_has_price = False
if course_honor_mode and course_honor_mode.min_price > 0:
course_mode_has_price = True
if (settings.FEATURES.get('INDIVIDUAL_DUE_DATES') and access['instructor']):
sections.insert(3, _section_extensions(course))
......@@ -77,6 +88,11 @@ def instructor_dashboard_2(request, course_id):
if settings.FEATURES['CLASS_DASHBOARD'] and access['staff']:
sections.append(_section_metrics(course_key, access))
# Gate access to Ecommerce tab
if course_mode_has_price:
sections.append(_section_e_commerce(course_key, access))
studio_url = None
if is_studio_course:
studio_url = get_cms_course_link(course)
......@@ -111,6 +127,29 @@ section_display_name will be used to generate link titles in the nav bar.
""" # pylint: disable=W0105
def _section_e_commerce(course_key, access):
""" Provide data for the corresponding dashboard section """
coupons = Coupon.objects.filter(course_id=course_key).order_by('-is_active')
total_amount = None
if access['finance_admin']:
total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(course_key)
section_data = {
'section_key': 'e-commerce',
'section_display_name': _('E-Commerce'),
'access': access,
'course_id': course_key.to_deprecated_string(),
'ajax_remove_coupon_url': reverse('remove_coupon', kwargs={'course_id': course_key.to_deprecated_string()}),
'ajax_get_coupon_info': reverse('get_coupon_info', kwargs={'course_id': course_key.to_deprecated_string()}),
'ajax_update_coupon': reverse('update_coupon', kwargs={'course_id': course_key.to_deprecated_string()}),
'ajax_add_coupon': reverse('add_coupon', kwargs={'course_id': course_key.to_deprecated_string()}),
'instructor_url': reverse('instructor_dashboard', kwargs={'course_id': course_key.to_deprecated_string()}),
'coupons': coupons,
'total_amount': total_amount,
}
return section_data
def _section_course_info(course_key, access):
""" Provide data for the corresponding dashboard section """
course = get_course_by_id(course_key, depth=None)
......
......@@ -28,6 +28,18 @@ class CourseDoesNotExistException(InvalidCartItem):
pass
class CouponDoesNotExistException(InvalidCartItem):
pass
class CouponAlreadyExistException(InvalidCartItem):
pass
class ItemDoesNotExistAgainstCouponException(InvalidCartItem):
pass
class ReportException(Exception):
pass
......
......@@ -31,7 +31,7 @@ from xmodule_django.models import CourseKeyField
from verify_student.models import SoftwareSecurePhotoVerification
from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException,
AlreadyEnrolledInCourseException, CourseDoesNotExistException)
AlreadyEnrolledInCourseException, CourseDoesNotExistException, CouponAlreadyExistException, ItemDoesNotExistAgainstCouponException)
from microsite_configuration import microsite
......@@ -217,6 +217,7 @@ class OrderItem(models.Model):
status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES, db_index=True)
qty = models.IntegerField(default=1)
unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30)
list_price = models.DecimalField(decimal_places=2, max_digits=30, null=True)
line_desc = models.CharField(default="Misc. Item", max_length=1024)
currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes
fulfilled_time = models.DateTimeField(null=True, db_index=True)
......@@ -304,6 +305,78 @@ class OrderItem(models.Model):
return ''
class CourseRegistrationCode(models.Model):
"""
This table contains registration codes
With registration code, a user can register for a course for free
"""
code = models.CharField(max_length=32, db_index=True)
course_id = CourseKeyField(max_length=255, db_index=True)
transaction_group_name = models.CharField(max_length=255, db_index=True, null=True, blank=True)
created_by = models.ForeignKey(User, related_name='created_by_user')
created_at = models.DateTimeField(default=datetime.now(pytz.utc))
redeemed_by = models.ForeignKey(User, null=True, related_name='redeemed_by_user')
redeemed_at = models.DateTimeField(default=datetime.now(pytz.utc), null=True)
class Coupon(models.Model):
"""
This table contains coupon codes
A user can get a discount offer on course if provide coupon code
"""
code = models.CharField(max_length=32, db_index=True)
description = models.CharField(max_length=255, null=True, blank=True)
course_id = CourseKeyField(max_length=255)
percentage_discount = models.IntegerField(default=0)
created_by = models.ForeignKey(User)
created_at = models.DateTimeField(default=datetime.now(pytz.utc))
is_active = models.BooleanField(default=True)
class CouponRedemption(models.Model):
"""
This table contain coupon redemption info
"""
order = models.ForeignKey(Order, db_index=True)
user = models.ForeignKey(User, db_index=True)
coupon = models.ForeignKey(Coupon, db_index=True)
@classmethod
def get_discount_price(cls, percentage_discount, value):
"""
return discounted price against coupon
"""
discount = Decimal("{0:.2f}".format(Decimal(percentage_discount / 100.00) * value))
return value - discount
@classmethod
def add_coupon_redemption(cls, coupon, order):
"""
add coupon info into coupon_redemption model
"""
cart_items = order.orderitem_set.all().select_subclasses()
for item in cart_items:
if getattr(item, 'course_id'):
if item.course_id == coupon.course_id:
coupon_redemption, created = cls.objects.get_or_create(order=order, user=order.user, coupon=coupon)
if not created:
log.exception("Coupon '{0}' already exist for user '{1}' against order id '{2}'"
.format(coupon.code, order.user.username, order.id))
raise CouponAlreadyExistException
discount_price = cls.get_discount_price(coupon.percentage_discount, item.unit_cost)
item.list_price = item.unit_cost
item.unit_cost = discount_price
item.save()
log.info("Discount generated for user {0} against order id '{1}' "
.format(order.user.username, order.id))
return coupon_redemption
log.warning("Course item does not exist for coupon '{0}'".format(coupon.code))
raise ItemDoesNotExistAgainstCouponException
class PaidCourseRegistration(OrderItem):
"""
This is an inventory item for paying for a course registration
......@@ -320,6 +393,19 @@ class PaidCourseRegistration(OrderItem):
for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")]
@classmethod
def get_total_amount_of_purchased_item(cls, course_key):
"""
This will return the total amount of money that a purchased course generated
"""
total_cost = 0
result = cls.objects.filter(course_id=course_key, status='purchased').aggregate(total=Sum('unit_cost', field='qty * unit_cost')) # pylint: disable=E1101
if result['total'] is not None:
total_cost = result['total']
return total_cost
@classmethod
@transaction.commit_on_success
def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None):
"""
......
......@@ -16,8 +16,25 @@ from django.utils.translation import ugettext as _
from edxmako.shortcuts import render_to_string
from shoppingcart.models import Order
from shoppingcart.processors.exceptions import *
from microsite_configuration import microsite
def get_cybersource_config():
"""
This method will return any microsite specific cybersource configuration, otherwise
we return the default configuration
"""
config_key = microsite.get_value('cybersource_config_key')
config = {}
if config_key:
# The microsite CyberSource configuration will be subkeys inside of the normal default
# CyberSource configuration
config = settings.CC_PROCESSOR['CyberSource']['microsites'][config_key]
else:
config = settings.CC_PROCESSOR['CyberSource']
return config
def process_postpay_callback(params):
"""
The top level call to this module, basically
......@@ -53,7 +70,7 @@ def processor_hash(value):
"""
Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page
"""
shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET', '')
shared_secret = get_cybersource_config().get('SHARED_SECRET', '')
hash_obj = hmac.new(shared_secret.encode('utf-8'), value.encode('utf-8'), sha1)
return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want
......@@ -63,9 +80,9 @@ def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='order
params needs to be an ordered dict, b/c cybersource documentation states that order is important.
Reverse engineered from PHP version provided by cybersource
"""
merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID', '')
order_page_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION', '7')
serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER', '')
merchant_id = get_cybersource_config().get('MERCHANT_ID', '')
order_page_version = get_cybersource_config().get('ORDERPAGE_VERSION', '7')
serial_number = get_cybersource_config().get('SERIAL_NUMBER', '')
params['merchantID'] = merchant_id
params['orderPage_timestamp'] = int(time.time() * 1000)
......@@ -123,7 +140,7 @@ def get_purchase_params(cart):
return params
def get_purchase_endpoint():
return settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '')
return get_cybersource_config().get('PURCHASE_ENDPOINT', '')
def payment_accepted(params):
"""
......@@ -215,7 +232,9 @@ def record_purchase(params, order):
def get_processor_decline_html(params):
"""Have to parse through the error codes to return a helpful message"""
payment_support_email = settings.PAYMENT_SUPPORT_EMAIL
# see if we have an override in the microsites
payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)
msg = dedent(_(
"""
......@@ -238,7 +257,8 @@ def get_processor_decline_html(params):
def get_processor_exception_html(exception):
"""Return error HTML associated with exception"""
payment_support_email = settings.PAYMENT_SUPPORT_EMAIL
# see if we have an override in the microsites
payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)
if isinstance(exception, CCProcessorDataException):
msg = dedent(_(
"""
......
......@@ -10,6 +10,8 @@ from shoppingcart.models import Order, OrderItem
from shoppingcart.processors.CyberSource import *
from shoppingcart.processors.exceptions import *
from mock import patch, Mock
from microsite_configuration import microsite
import mock
TEST_CC_PROCESSOR = {
......@@ -19,10 +21,28 @@ TEST_CC_PROCESSOR = {
'SERIAL_NUMBER': '12345',
'ORDERPAGE_VERSION': '7',
'PURCHASE_ENDPOINT': '',
'microsites': {
'test_microsite': {
'SHARED_SECRET': 'secret_override',
'MERCHANT_ID': 'edx_test_override',
'SERIAL_NUMBER': '12345_override',
'ORDERPAGE_VERSION': '7',
'PURCHASE_ENDPOINT': '',
}
}
}
}
def fakemicrosite(name, default=None):
"""
This is a test mocking function to return a microsite configuration
"""
if name == 'cybersource_config_key':
return 'test_microsite'
else:
return None
@override_settings(CC_PROCESSOR=TEST_CC_PROCESSOR)
class CyberSourceTests(TestCase):
......@@ -33,6 +53,15 @@ class CyberSourceTests(TestCase):
self.assertEqual(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test')
self.assertEqual(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret')
def test_microsite_no_override_settings(self):
self.assertEqual(get_cybersource_config()['MERCHANT_ID'], 'edx_test')
self.assertEqual(get_cybersource_config()['SHARED_SECRET'], 'secret')
@mock.patch("microsite_configuration.microsite.get_value", fakemicrosite)
def test_microsite_override_settings(self):
self.assertEqual(get_cybersource_config()['MERCHANT_ID'], 'edx_test_override')
self.assertEqual(get_cybersource_config()['SHARED_SECRET'], 'secret_override')
def test_hash(self):
"""
Tests the hash function. Basically just hardcodes the answer.
......
......@@ -14,6 +14,7 @@ if settings.FEATURES['ENABLE_SHOPPING_CART']:
url(r'^clear/$', 'clear_cart'),
url(r'^remove_item/$', 'remove_item'),
url(r'^add/course/{}/$'.format(settings.COURSE_ID_PATTERN), 'add_course_to_cart', name='add_course_to_cart'),
url(r'^use_coupon/$', 'use_coupon'),
)
if settings.FEATURES.get('ENABLE_PAYMENT_FAKE'):
......
......@@ -14,9 +14,10 @@ from edxmako.shortcuts import render_to_response
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from shoppingcart.reports import RefundReport, ItemizedPurchaseReport, UniversityRevenueShareReport, CertificateStatusReport
from student.models import CourseEnrollment
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportTypeDoesNotExistException
from .models import Order, PaidCourseRegistration, OrderItem
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportTypeDoesNotExistException, CouponAlreadyExistException, ItemDoesNotExistAgainstCouponException
from .models import Order, PaidCourseRegistration, OrderItem, Coupon, CouponRedemption
from .processors import process_postpay_callback, render_purchase_form_html
import json
log = logging.getLogger("shoppingcart")
......@@ -69,6 +70,16 @@ def show_cart(request):
cart = Order.get_cart_for_user(request.user)
total_cost = cart.total_cost
cart_items = cart.orderitem_set.all()
# add the request protocol, domain, and port to the cart object so that any specific
# CC_PROCESSOR implementation can construct callback URLs, if necessary
cart.context = {
'request_domain': '{0}://{1}'.format(
'https' if request.is_secure() else 'http',
request.get_host()
)
}
form_html = render_purchase_form_html(cart)
return render_to_response("shoppingcart/list.html",
{'shoppingcart_items': cart_items,
......@@ -81,6 +92,11 @@ def show_cart(request):
def clear_cart(request):
cart = Order.get_cart_for_user(request.user)
cart.clear()
coupon_redemption = CouponRedemption.objects.filter(user=request.user, order=cart.id)
if coupon_redemption:
coupon_redemption.delete()
log.info('Coupon redemption entry removed for user {0} for order {1}'.format(request.user, cart.id))
return HttpResponse('Cleared')
......@@ -90,12 +106,50 @@ def remove_item(request):
try:
item = OrderItem.objects.get(id=item_id, status='cart')
if item.user == request.user:
order_item_course_id = None
if hasattr(item, 'paidcourseregistration'):
order_item_course_id = item.paidcourseregistration.course_id
item.delete()
log.info('order item {0} removed for user {1}'.format(item_id, request.user))
try:
coupon_redemption = CouponRedemption.objects.get(user=request.user, order=item.order_id)
if order_item_course_id == coupon_redemption.coupon.course_id:
coupon_redemption.delete()
log.info('Coupon "{0}" redemption entry removed for user "{1}" for order item "{2}"'
.format(coupon_redemption.coupon.code, request.user, item_id))
except CouponRedemption.DoesNotExist:
log.debug('Coupon redemption does not exist for order item id={0}.'.format(item_id))
except OrderItem.DoesNotExist:
log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id))
return HttpResponse('OK')
@login_required
def use_coupon(request):
"""
This method generate discount against valid coupon code and save its entry into coupon redemption table
"""
coupon_code = request.POST["coupon_code"]
try:
coupon = Coupon.objects.get(code=coupon_code)
except Coupon.DoesNotExist:
return HttpResponseNotFound(_("Discount does not exist against coupon '{0}'.".format(coupon_code)))
if coupon.is_active:
try:
cart = Order.get_cart_for_user(request.user)
CouponRedemption.add_coupon_redemption(coupon, cart)
except CouponAlreadyExistException:
return HttpResponseBadRequest(_("Coupon '{0}' already used.".format(coupon_code)))
except ItemDoesNotExistAgainstCouponException:
return HttpResponseNotFound(_("Coupon '{0}' is not valid for any course in the shopping cart.".format(coupon_code)))
response = HttpResponse(json.dumps({'response': 'success'}), content_type="application/json")
return response
else:
return HttpResponseBadRequest(_("Coupon '{0}' is inactive.".format(coupon_code)))
@csrf_exempt
@require_POST
def postpay_callback(request):
......@@ -122,6 +176,7 @@ def show_receipt(request, ordernum):
Displays a receipt for a particular order.
404 if order is not yet purchased or request.user != order.user
"""
try:
order = Order.objects.get(id=ordernum)
except Order.DoesNotExist:
......
......@@ -597,7 +597,7 @@ section.instructor-dashboard-content-2 {
float: left;
clear: both;
margin-top: 25px;
.metrics-left, .metrics-left-header {
position: relative;
width: 30%;
......@@ -611,7 +611,7 @@ section.instructor-dashboard-content-2 {
.metrics-section.metrics-left {
height: 640px;
}
.metrics-right, .metrics-right-header {
position: relative;
width: 65%;
......@@ -627,7 +627,7 @@ section.instructor-dashboard-content-2 {
.metrics-section.metrics-right {
height: 295px;
}
svg {
.stacked-bar {
cursor: pointer;
......@@ -775,3 +775,267 @@ input[name="subject"] {
font-weight: bold;
}
}
.ecommerce-wrapper{
h2{
height: 26px;
line-height: 26px;
span{
float: right;
font-size: 16px;
font-weight: bold;
span{
background: #ddd;
padding: 2px 9px;
border-radius: 2px;
float: none;
font-weight: 400;
}
}
}
span.tip{
padding: 10px 15px;
display: block;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
background: #f8f4ec;
color: #3c3c3c;
line-height: 30px;
.add{
@include button(simple, $blue);
@extend .button-reset;
font-size: em(13);
float: right;
}
}
}
#e-commerce{
.coupon-errors {
background: #FFEEF5;color:#B72667;text-align: center;padding: 10px 0px;
font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif;font-size: 15px;
border-bottom: 1px solid #B72667;
margin-bottom: 20px;
display: none;
}
.content{
padding: 0 !important;
}
.coupons-table {
width: 100%;
tr:nth-child(even){
background-color: #f8f8f8;
border-bottom: 1px solid #f3f3f3;
}
tr.always-gray{
background: #eee !important;
border-top: 2px solid #FFFFFF;
}
tr.always-white{
background: #fff !important;
td{
padding: 30px 0px 10px;
}
}
.coupons-headings {
height: 40px;
border-bottom: 1px solid #BEBEBE;
th:nth-child(5){
text-align: center;
width: 120px;
}
th:first-child{
padding-left: 20px;
}
th {
text-align: left;
border-bottom: 1px solid $border-color-1;
&.c_code {
width: 170px;
}
&.c_count {
width: 85px;
}
&.c_course_id {
width: 320px;
word-wrap: break-word;
}
&.c_discount {
width: 90px;
}
&.c_action {
width: 89px;
}
&.c_dsc{
width: 260px;
word-wrap: break-word;
}
}
}
// in_active coupon rows style
.inactive_coupon{
background: #FFF0F0 !important;
text-decoration: line-through;
color: rgba(51,51,51,0.2);
border-bottom: 1px solid #fff;
td {
a {
color: rgba(51,51,51,0.2);
}
}
}
// coupon items style
.coupons-items {
td {
padding: 10px 0px;
position: relative;
line-height: normal;
span.old-price{
left: -75px;
position: relative;
text-decoration: line-through;
color: red;
font-size: 12px;
top: -1px;
}
}
td:nth-child(5),td:first-child{
padding-left: 20px;
}
td:nth-child(2){
line-height: 22px;
padding-right: 0px;
word-wrap: break-word;
}
td:nth-child(5){
padding-left: 0;
text-align: center;
}
td{
a.edit-right{
margin-left: 15px;
}
}
}
}
// coupon edit and add modals
#add-coupon-modal, #edit-coupon-modal{
.inner-wrapper {
background: #fff;
}
top:-95px !important;
width: 650px;
margin-left: -325px;
border-radius: 2px;
input[type="submit"]#update_coupon_button{
@include button(simple, $blue);
@extend .button-reset;
}
input[type="submit"]#add_coupon_button{
@include button(simple, $blue);
@extend .button-reset;
}
.modal-form-error {
box-shadow: inset 0 -1px 2px 0 #f3d9db;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin: 20px 0 10px 0 !important;
padding: 20px;
border: none;
border-bottom: 3px solid #a0050e;
background: #fbf2f3;
}
ol.list-input{
li{
width: 278px;
float: left;
label.required:after {
content: "*";
margin-left: 5px;
}
}
li:nth-child(even){
margin-left: 30px !important;
}
li:nth-child(3), li:nth-child(4){
margin-left: 0px !important;
width: 100%;
}
li:nth-child(3) {
margin-bottom: 0px !important;
textarea {
min-height: 100px;
}
}
li:last-child{
margin-bottom: 0px !important;
}
}
#coupon-content {
padding: 20px;
header {
margin: 0;
padding: 0;
h2 {
font-size: 24px;
font-weight: 100;
color: #1580b0;
text-align: left;
}
}
.instructions p {
margin-bottom: 5px;
}
form {
border-radius: 0;
box-shadow: none;
margin: 0;
border: none;
padding: 0;
.group-form {
margin: 0;
padding-top: 0;
padding-bottom: 20px;
}
.list-input {
margin: 0;
padding: 0;
list-style: none;
}
.readonly {
background-color: #eee !important;
color: #aaa;
}
.field {
margin: 0 0 20px 0;
}
.field.required label {
font-weight: 600;
}
.field label {
-webkit-transition: color 0.15s ease-in-out 0s;
-moz-transition: color 0.15s ease-in-out 0s;
transition: color 0.15s ease-in-out 0s;
margin: 0 0 5px 0;
color: #333;
}
.field.text input {
background: #fff;
margin-bottom: 0;
}
.field input {
width: 100%;
margin: 0;
padding: 10px 15px;
}
}
}
}
}
......@@ -12,14 +12,20 @@
border: 1px solid $red;
}
.cart-errors{
background: #FFEEF5;color:#B72667;text-align: center;padding: 10px 0px;
font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif;font-size: 15px;
border-bottom: 1px solid #B72667;
margin-bottom: 20px;
display: none;
}
.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;
......@@ -27,13 +33,42 @@
.cart-table {
width: 100%;
tr:nth-child(even){
background-color: #f8f8f8;
border-bottom: 1px solid #f3f3f3;
}
tr.always-gray{
background: #eee !important;
border-top: 2px solid #FFFFFF;
}
tr.always-white{
background: #fff !important;
td{
padding: 30px 0px 10px;
}
}
tr{
td.cart-total{
padding: 10px 0;
span{
display: inline-block;
margin-right: 15px;
margin-left: 15px;
font-weight: bold;
}
}
}
.cart-headings {
height: 35px;
border-bottom: 1px solid #BEBEBE;
th:nth-child(5),th:first-child{
text-align: center;
width: 120px;
}
th {
text-align: left;
padding-left: 5px;
border-bottom: 1px solid $border-color-1;
&.qty {
......@@ -48,12 +83,35 @@
&.cur {
width: 100px;
}
&.dsc{
width: 640px;
padding-right: 50px;
}
}
}
.cart-items {
td {
padding: 10px 25px;
padding: 10px 0px;
position: relative;
line-height: normal;
span.old-price{
left: -75px;
position: relative;
text-decoration: line-through;
color: red;
font-size: 12px;
top: -1px;
}
}
td:nth-child(5),td:first-child{
text-align: center;
}
td:nth-child(2){
line-height: 22px;
padding-right: 50px;
}
}
......@@ -64,6 +122,7 @@
font-weight: bold;
padding: 10px 25px;
}
}
}
}
......@@ -80,11 +139,10 @@
.items-ordered {
padding-top: 50px;
}
tr {
}
th {
text-align: left;
padding: 25px 0 15px 0;
......@@ -105,6 +163,9 @@
tr.order-item {
td {
padding-bottom: 10px;
span.old-price{
text-decoration: line-through !important;
}
}
}
}
......
<%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %>
<%page args="section_data"/>
<section id="add-coupon-modal" class="modal" role="dialog" tabindex="-1" aria-label="${_('Password Reset')}">
<div class="inner-wrapper">
<button class="close-modal">
<i class="icon-remove"></i>
<span class="sr">
## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen)
${_('Close')}
</span>
</button>
<div id="coupon-content">
<header>
<h2>${_("Add Coupon")}</h2>
</header>
<div class="instructions">
<p>
${_("Please enter Coupon detail below")}</p>
</div>
<form id="add_coupon_form" action="${section_data['ajax_add_coupon']}" method="post" data-remote="true">
<div id="coupon_form_error" class="modal-form-error"></div>
<fieldset class="group group-form group-form-requiredinformation">
<legend class="is-hidden">${_("Required Information")}</legend>
<ol class="list-input">
<li class="field required text" id="add-coupon-modal-field-code">
<label for="coupon_code" class="required">${_("Code")}</label>
<input class="" id="coupon_code" type="text" name="code" maxlength="16" value="" placeholder="example: A123DS"
aria-required="true"/>
</li>
<li class="field required text" id="add-coupon-modal-field-discount">
<label for="coupon_discount" class="required text">${_("Percentage Discount")}</label>
<input class="field required" id="coupon_discount" type="text" name="discount" value="" maxlength="5"
aria-required="true"/>
</li>
<li class="field" id="add-coupon-modal-field-description">
<label for="coupon_description">${_("Description")}</label>
<textarea class="field" id="coupon_description" type="text" name="description" value=""
aria-describedby="pwd_reset_email-tip" aria-required="true"> </textarea>
</li>
<li class="field" id="add-coupon-modal-field-course_id">
<label for="coupon_course_id">${_("Course ID")}</label>
<input class="field readonly" id="coupon_course_id" type="text" name="course_id" value="${section_data['course_id']}"
readonly aria-required="true"/>
</li>
</ol>
</fieldset>
<div class="submit">
<input name="submit" type="submit" id="add_coupon_button" value="${_('Add Coupon')}"/>
</div>
</form>
</div>
</div>
</section>
<%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %>
<%page args="section_data"/>
<section id="edit-coupon-modal" class="modal" role="dialog" tabindex="-1" aria-label="${_('Edit Coupon')}">
<div class="inner-wrapper">
<button class="close-modal">
<i class="icon-remove"></i>
<span class="sr">
## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen)
${_('Close')}
</span>
</button>
<div id="coupon-content">
<header>
<h2>${_("Update Coupon")}</h2>
</header>
<div class="instructions">
<p>
${_("Update Coupon Information")}</p>
</div>
<form id="edit_coupon_form" action="${section_data['ajax_update_coupon']}" method="post" data-remote="true">
<div id="coupon_form_error" class="modal-form-error"></div>
<fieldset class="group group-form group-form-requiredinformation">
<legend class="is-hidden">${_("Required Information")}</legend>
<ol class="list-input">
<li class="field required text" id="edit-coupon-modal-field-code">
<label for="edit_coupon_code" class="required">${_("Code")}</label>
<input class="field" id="edit_coupon_code" type="text" name="code" maxlength="16" value="" placeholder="example: A123DS"
aria-required="true"/>
</li>
<li class="field required text" id="edit-coupon-modal-field-discount">
<label for="edit_coupon_discount" class="required">${_("Percentage Discount")}</label>
<input class="field" id="edit_coupon_discount" type="text" name="discount" value="" maxlength="5"
aria-required="true"/>
</li>
<li class="field" id="edit-coupon-modal-field-description">
<label for="edit_coupon_description">${_("Description")}</label>
<textarea class="field" id="edit_coupon_description" type="text" name="description" value=""
aria-required="true"></textarea>
</li>
<li class="field" id="edit-coupon-modal-field-course_id">
<label for="edit_coupon_course_id">${_("Course ID")}</label>
<input class="field readonly" id="edit_coupon_course_id" type="text" name="course_id" value=""
readonly aria-required="true"/>
</li>
</ol>
</fieldset>
<div class="submit">
<input type="hidden" name="coupon_id" id="coupon_id"/>
<input name="submit" type="submit" id="update_coupon_button" value="${_('Update Coupon')}"/>
</div>
</form>
</div>
</div>
</section>
......@@ -2,5 +2,6 @@
% for pk, pv in params.iteritems():
<input type="hidden" name="${pk}" value="${pv}" />
% endfor
<input type="submit" value="Check Out" />
</form>
</form>
\ No newline at end of file
......@@ -8,6 +8,7 @@
<section class="container cart-list">
<h2>${_("Your selected items:")}</h2>
<h3 class="cart-errors" id="cart-error">Error goes here.</h3>
% if shoppingcart_items:
<table class="cart-table">
<thead>
......@@ -24,24 +25,39 @@
<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.unit_cost)}
% if item.list_price != None:
<span class="old-price"> ${"{0:0.2f}".format(item.list_price)}</span>
% endif
</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 class="always-gray">
<td colspan="3"></td>
<td colspan="3" valign="middle" class="cart-total" align="right">
<b>${_("Total Amount")}: <span> ${"{0:0.2f}".format(amount)} </span> </b>
</td>
</tr>
</tbody>
<tfoot>
<tr class="always-white">
<td colspan="2">
<input type="text" placeholder="Enter coupon code here" name="coupon_code" id="couponCode">
<input type="button" value="Use Coupon" id="cart-coupon">
</td>
<td colspan="4" align="right">
${form_html}
</td>
</tr>
</tfoot>
</table>
<!-- <input id="back_input" type="submit" value="Return" /> -->
${form_html}
% else:
<p>${_("You have selected no items for purchase.")}</p>
% endif
......@@ -60,9 +76,39 @@
});
});
$('#cart-coupon').click(function(event){
event.preventDefault();
var post_url = "${reverse('shoppingcart.views.use_coupon')}";
$.post(post_url,{
"coupon_code" : $('#couponCode').val(),
beforeSend: function(xhr, options){
if($('#couponCode').val() == "") {
showErrorMsgs('Must contain a valid coupon code')
xhr.abort();
}
}
}
)
.success(function(data) {
location.reload(true);
})
.error(function(data,status) {
if(status=="parsererror"){
location.reload(true);
}else{
showErrorMsgs(data.responseText)
}
})
});
$('#back_input').click(function(){
history.back();
});
function showErrorMsgs(msg){
$(".cart-errors").css('display', 'block');
$("#cart-error").html(msg);
}
});
</script>
......@@ -3,6 +3,7 @@
<%! from django.conf import settings %>
<%inherit file="../main.html" />
<%block name="bodyclass">purchase-receipt</%block>
<%block name="pagetitle">${_("Register for [Course Name] | Receipt (Order")} ${order.id})</%block>
......@@ -37,16 +38,25 @@
<tr>
<th class="qty">${_("Qty")}</th>
<th class="desc">${_("Description")}</th>
<th class="url">${_("URL")}</th>
<th class="u-pr">${_("Unit Price")}</th>
<th class="pri">${_("Price")}</th>
<th class="curr">${_("Currency")}</th>
</tr>
% for item in order_items:
<% course_id = reverse('info', args=[item.course_id.to_deprecated_string()]) %>
<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><a href="${course_id}" class="enter-course">${_('View Course')}</a></td>
<td>${"{0:0.2f}".format(item.unit_cost)}
% if item.list_price != None:
<span class="old-price"> ${"{0:0.2f}".format(item.list_price)}</span>
% endif
</td>
<td>${"{0:0.2f}".format(item.line_cost)}</td>
<td>${item.currency.upper()}</td></tr>
% elif item.status == "refunded":
......
......@@ -283,6 +283,14 @@ if settings.COURSEWARE_ENABLED:
'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard"),
url(r'^courses/{}/instructor/api/'.format(settings.COURSE_ID_PATTERN),
include('instructor.views.api_urls')),
url(r'^courses/{}/remove_coupon$'.format(settings.COURSE_ID_PATTERN),
'instructor.views.coupons.remove_coupon', name="remove_coupon"),
url(r'^courses/{}/add_coupon$'.format(settings.COURSE_ID_PATTERN),
'instructor.views.coupons.add_coupon', name="add_coupon"),
url(r'^courses/{}/update_coupon$'.format(settings.COURSE_ID_PATTERN),
'instructor.views.coupons.update_coupon', name="update_coupon"),
url(r'^courses/{}/get_coupon_info$'.format(settings.COURSE_ID_PATTERN),
'instructor.views.coupons.get_coupon_info', name="get_coupon_info"),
# see ENABLE_INSTRUCTOR_LEGACY_DASHBOARD section for legacy dash urls
......
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