diff --git a/common/djangoapps/terrain/stubs/ecommerce.py b/common/djangoapps/terrain/stubs/ecommerce.py
new file mode 100644
index 0000000000..082b09e6cf
--- /dev/null
+++ b/common/djangoapps/terrain/stubs/ecommerce.py
@@ -0,0 +1,64 @@
+"""
+Stub implementation of ecommerce service for acceptance tests
+"""
+
+import re
+import urlparse
+from .http import StubHttpRequestHandler, StubHttpService
+
+
+class StubEcommerceServiceHandler(StubHttpRequestHandler): # pylint: disable=missing-docstring
+
+ def do_GET(self): # pylint: disable=invalid-name, missing-docstring
+ pattern_handlers = {
+ '/api/v2/orders/$': self.get_orders_list,
+ }
+ if self.match_pattern(pattern_handlers):
+ return
+ self.send_response(404, content='404 Not Found')
+
+ def match_pattern(self, pattern_handlers):
+ """
+ Find the correct handler method given the path info from the HTTP request.
+ """
+ path = urlparse.urlparse(self.path).path
+ for pattern in pattern_handlers:
+ match = re.match(pattern, path)
+ if match:
+ pattern_handlers[pattern](**match.groupdict())
+ return True
+ return None
+
+ def get_orders_list(self):
+ """
+ Stubs the orders list endpoint.
+ """
+ orders = {
+ 'results': [
+ {
+ 'status': 'Complete',
+ 'number': 'Edx-123',
+ 'total_excl_tax': '100.0',
+ 'date_placed': '2016-04-21T23:14:23Z',
+ 'lines': [
+ {
+ 'title': 'Test Course',
+ 'product': {
+ 'attribute_values': [
+ {
+ 'name': 'certificate_type',
+ 'value': 'verified'
+ }
+ ]
+ }
+ }
+ ],
+ }
+ ]
+ }
+ orders = self.server.config.get('orders', orders)
+ self.send_json_response(orders)
+
+
+class StubEcommerceService(StubHttpService): # pylint: disable=missing-docstring
+ HANDLER_CLASS = StubEcommerceServiceHandler
diff --git a/common/djangoapps/terrain/stubs/start.py b/common/djangoapps/terrain/stubs/start.py
index a5bee75f3e..ad6d826eac 100644
--- a/common/djangoapps/terrain/stubs/start.py
+++ b/common/djangoapps/terrain/stubs/start.py
@@ -4,7 +4,9 @@ Command-line utility to start a stub service.
import sys
import time
import logging
+
from .comments import StubCommentsService
+from .ecommerce import StubEcommerceService
from .xqueue import StubXQueueService
from .youtube import StubYouTubeService
from .lti import StubLtiService
@@ -23,6 +25,7 @@ SERVICES = {
'video': VideoSourceHttpService,
'edxnotes': StubEdxNotesService,
'programs': StubProgramsService,
+ 'ecommerce': StubEcommerceService,
}
# Log to stdout, including debug messages
diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py
index 84ab7e07e6..ee879390b4 100644
--- a/common/djangoapps/third_party_auth/tests/specs/base.py
+++ b/common/djangoapps/third_party_auth/tests/specs/base.py
@@ -13,10 +13,12 @@ from django.contrib.sessions.backends import cache
from django.core.urlresolvers import reverse
from django.test import utils as django_utils
from django.conf import settings as django_settings
-from edxmako.tests import mako_middleware_process_request
from social import actions, exceptions
from social.apps.django_app import utils as social_utils
from social.apps.django_app import views as social_views
+
+from edxmako.tests import mako_middleware_process_request
+from lms.djangoapps.commerce.tests import TEST_API_URL, TEST_API_SIGNING_KEY
from student import models as student_models
from student import views as student_views
from student.tests.factories import UserFactory
@@ -898,7 +900,9 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
-class Oauth2IntegrationTest(IntegrationTest): # pylint: disable=abstract-method
+# pylint: disable=test-inherits-tests, abstract-method
+@django_utils.override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
+class Oauth2IntegrationTest(IntegrationTest):
"""Base test case for integration tests of Oauth2 providers."""
# Dict of string -> object. Information about the token granted to the
diff --git a/common/test/acceptance/pages/lms/account_settings.py b/common/test/acceptance/pages/lms/account_settings.py
index 33d852de43..d3e6e38405 100644
--- a/common/test/acceptance/pages/lms/account_settings.py
+++ b/common/test/acceptance/pages/lms/account_settings.py
@@ -63,3 +63,19 @@ class AccountSettingsPage(FieldsMixin, PageObject):
Switch between the different account settings tabs.
"""
self.q(css='#{}'.format(tab_id)).click()
+
+ @property
+ def is_order_history_tab_visible(self):
+ """ Check if tab with the name "Order History" is visible."""
+ return self.q(css='.u-field-orderHistory').visible
+
+ def get_value_of_order_history_row_item(self, field_id, field_name):
+ """ Return the text value of the provided order field name."""
+ query = self.q(css='.u-field-{} .u-field-order-{}'.format(field_id, field_name))
+ return query.text[0] if query.present else None
+
+ def order_button_is_visible(self, field_id):
+ """ Check that if hovering over the order history row shows the
+ order detail link or not.
+ """
+ return self.q(css='.u-field-{} .u-field-{}'.format(field_id, 'link')).visible
diff --git a/common/test/acceptance/tests/lms/test_account_settings.py b/common/test/acceptance/tests/lms/test_account_settings.py
index 09d1ec457c..02b8c84889 100644
--- a/common/test/acceptance/tests/lms/test_account_settings.py
+++ b/common/test/acceptance/tests/lms/test_account_settings.py
@@ -444,6 +444,28 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title)
+ def test_order_history(self):
+ """
+ Test that we can see orders on Order History tab.
+ """
+ # switch to "Order History" tab
+ self.account_settings_page.switch_account_settings_tabs('orders-tab')
+ # verify that we are on correct tab
+ self.assertTrue(self.account_settings_page.is_order_history_tab_visible)
+
+ expected_order_data = {
+ 'title': 'Test Course',
+ 'date': 'Date Placed:\nApr 21, 2016',
+ 'price': 'Cost:\n$100.0',
+ 'number': 'Order Number:\nEdx-123'
+ }
+ for field_name, value in expected_order_data.iteritems():
+ self.assertEqual(
+ self.account_settings_page.get_value_of_order_history_row_item('order-Edx-123', field_name), value
+ )
+
+ self.assertTrue(self.account_settings_page.order_button_is_visible('order-Edx-123'))
+
@attr('a11y')
class AccountSettingsA11yTest(AccountSettingsTestMixin, WebAppTest):
diff --git a/common/test/db_fixtures/commerce_config.json b/common/test/db_fixtures/commerce_config.json
new file mode 100644
index 0000000000..473546f88c
--- /dev/null
+++ b/common/test/db_fixtures/commerce_config.json
@@ -0,0 +1,10 @@
+[
+ {
+ "pk": 2,
+ "model": "commerce.commerceconfiguration",
+ "fields": {
+ "enabled": 1,
+ "change_date": "2016-04-21 10:19:32.034856"
+ }
+ }
+]
diff --git a/lms/djangoapps/commerce/migrations/0004_auto_20160531_0950.py b/lms/djangoapps/commerce/migrations/0004_auto_20160531_0950.py
new file mode 100644
index 0000000000..96e076d5b1
--- /dev/null
+++ b/lms/djangoapps/commerce/migrations/0004_auto_20160531_0950.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('commerce', '0003_auto_20160329_0709'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='commerceconfiguration',
+ name='cache_ttl',
+ field=models.PositiveIntegerField(default=0, help_text='Specified in seconds. Enable caching by setting this to a value greater than 0.', verbose_name='Cache Time To Live'),
+ ),
+ migrations.AddField(
+ model_name='commerceconfiguration',
+ name='receipt_page',
+ field=models.CharField(default=b'/commerce/checkout/receipt/?orderNum=', help_text='Path to order receipt page.', max_length=255),
+ ),
+ ]
diff --git a/lms/djangoapps/commerce/models.py b/lms/djangoapps/commerce/models.py
index 287ea91964..586a69ade6 100644
--- a/lms/djangoapps/commerce/models.py
+++ b/lms/djangoapps/commerce/models.py
@@ -13,6 +13,9 @@ class CommerceConfiguration(ConfigurationModel):
class Meta(object):
app_label = "commerce"
+ API_NAME = 'commerce'
+ CACHE_KEY = 'commerce.api.data'
+
checkout_on_ecommerce_service = models.BooleanField(
default=False,
help_text=_('Use the checkout page hosted by the E-Commerce service.')
@@ -23,6 +26,23 @@ class CommerceConfiguration(ConfigurationModel):
default='/basket/single-item/',
help_text=_('Path to single course checkout page hosted by the E-Commerce service.')
)
+ cache_ttl = models.PositiveIntegerField(
+ verbose_name=_('Cache Time To Live'),
+ default=0,
+ help_text=_(
+ 'Specified in seconds. Enable caching by setting this to a value greater than 0.'
+ )
+ )
+ receipt_page = models.CharField(
+ max_length=255,
+ default='/commerce/checkout/receipt/?orderNum=',
+ help_text=_('Path to order receipt page.')
+ )
def __unicode__(self):
return "Commerce configuration"
+
+ @property
+ def is_cache_enabled(self):
+ """Whether responses from the Ecommerce API will be cached."""
+ return self.cache_ttl > 0
diff --git a/lms/djangoapps/commerce/tests/factories.py b/lms/djangoapps/commerce/tests/factories.py
new file mode 100644
index 0000000000..8a124b3b5c
--- /dev/null
+++ b/lms/djangoapps/commerce/tests/factories.py
@@ -0,0 +1,55 @@
+""" Factories for generating fake commerce-related data. """
+import factory
+from factory.fuzzy import FuzzyText
+
+
+class OrderFactory(factory.Factory):
+ """ Factory for stubbing orders resources from Ecommerce (v2). """
+ class Meta(object):
+ model = dict
+
+ number = factory.Sequence(lambda n: 'edx-%d' % n)
+ date_placed = '2016-01-01T10:00:00Z'
+ status = 'Complete'
+ currency = 'USD'
+ total_excl_tax = '100.00'
+ lines = []
+
+
+class OrderLineFactory(factory.Factory):
+ """ Factory for stubbing order lines resources from Ecommerce (v2). """
+ class Meta(object):
+ model = dict
+
+ title = FuzzyText(prefix='Seat in ')
+ quantity = 1
+ description = FuzzyText()
+ status = 'Complete'
+ line_price_excl_tax = '100.00'
+ unit_price_excl_tax = '100.00'
+ product = {}
+
+
+class ProductFactory(factory.Factory):
+ """ Factory for stubbing Product resources from Ecommerce (v2). """
+ class Meta(object):
+ model = dict
+
+ id = factory.Sequence(lambda n: n) # pylint: disable=invalid-name
+ url = 'http://test/api/v2/products/' + str(id)
+ product_class = 'Seat'
+ title = FuzzyText(prefix='Seat in ')
+ price = '100.00'
+ attribute_values = []
+
+
+class ProductAttributeFactory(factory.Factory):
+ """ Factory for stubbing product attribute resources from
+ Ecommerce (v2).
+ """
+ class Meta(object):
+ model = dict
+
+ name = FuzzyText()
+ code = FuzzyText()
+ value = FuzzyText()
diff --git a/lms/djangoapps/commerce/tests/mocks.py b/lms/djangoapps/commerce/tests/mocks.py
index 4d6fcfdd35..933e4bbb01 100644
--- a/lms/djangoapps/commerce/tests/mocks.py
+++ b/lms/djangoapps/commerce/tests/mocks.py
@@ -3,7 +3,7 @@ import json
import httpretty
-from commerce.tests import TEST_API_URL
+from commerce.tests import TEST_API_URL, factories
class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name
@@ -117,3 +117,26 @@ class mock_order_endpoint(mock_ecommerce_api_endpoint): # pylint: disable=inval
def get_uri(self):
return TEST_API_URL + '/orders/{}/'.format(self.order_number)
+
+
+class mock_get_orders(mock_ecommerce_api_endpoint): # pylint: disable=invalid-name
+ """ Mocks calls to E-Commerce API client order get method. """
+
+ default_response = {
+ 'results': [
+ factories.OrderFactory(
+ lines=[
+ factories.OrderLineFactory(
+ product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory(
+ name='certificate_type',
+ value='verified'
+ )])
+ )
+ ]
+ )
+ ]
+ }
+ method = httpretty.GET
+
+ def get_uri(self):
+ return TEST_API_URL + '/orders/'
diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py
index 291bff1bc6..f24df08ba8 100644
--- a/lms/djangoapps/commerce/views.py
+++ b/lms/djangoapps/commerce/views.py
@@ -3,14 +3,16 @@ import logging
from django.conf import settings
from django.contrib.auth.decorators import login_required
+from django.core.cache import cache
+from django.utils.translation import ugettext as _
from django.views.decorators.csrf import csrf_exempt
+from commerce.models import CommerceConfiguration
from edxmako.shortcuts import render_to_response
from microsite_configuration import microsite
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
-from shoppingcart.processors.CyberSource2 import is_user_payment_error
-from django.utils.translation import ugettext as _
from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site
+from shoppingcart.processors.CyberSource2 import is_user_payment_error
log = logging.getLogger(__name__)
@@ -65,6 +67,13 @@ def checkout_receipt(request):
"If your course does not appear on your dashboard, contact {payment_support_link}."
).format(payment_support_link=payment_support_link)
+ commerce_configuration = CommerceConfiguration.current()
+ # user order cache should be cleared when a new order is placed
+ # so user can see new order in their order history.
+ if is_payment_complete and commerce_configuration.enabled and commerce_configuration.is_cache_enabled:
+ cache_key = commerce_configuration.CACHE_KEY + '.' + str(request.user.id)
+ cache.delete(cache_key)
+
context = {
'page_title': page_title,
'is_payment_complete': is_payment_complete,
diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py
index 4482d54341..7cf2f0cec3 100644
--- a/lms/djangoapps/student_account/test/test_views.py
+++ b/lms/djangoapps/student_account/test/test_views.py
@@ -17,14 +17,19 @@ from django.contrib.messages.middleware import MessageMiddleware
from django.test import TestCase
from django.test.utils import override_settings
from django.http import HttpRequest
+from edx_rest_api_client import exceptions
from course_modes.models import CourseMode
+from commerce.models import CommerceConfiguration
+from commerce.tests import TEST_API_URL, TEST_API_SIGNING_KEY, factories
+from commerce.tests.mocks import mock_get_orders
+from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account
from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH
from openedx.core.djangolib.js_utils import dump_js_escaped_json
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from student.tests.factories import UserFactory
-from student_account.views import account_settings_context
+from student_account.views import account_settings_context, get_user_orders
from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@@ -442,7 +447,8 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
})
-class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase):
+@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
+class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase, ProgramsApiConfigMixin):
""" Tests for the account settings view. """
USERNAME = 'student'
@@ -461,6 +467,7 @@ class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase):
def setUp(self):
super(AccountSettingsViewTest, self).setUp()
self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
+ CommerceConfiguration.objects.create(cache_ttl=10, enabled=True)
self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.request = HttpRequest()
@@ -508,6 +515,86 @@ class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase):
for attribute in self.FIELDS:
self.assertIn(attribute, response.content)
+ def test_header_with_programs_listing_enabled(self):
+ """
+ Verify that tabs header will be shown while program listing is enabled.
+ """
+ self.create_programs_config(program_listing_enabled=True)
+ view_path = reverse('account_settings')
+ response = self.client.get(path=view_path)
+
+ self.assertContains(response, '
')
+
+ def test_header_with_programs_listing_disabled(self):
+ """
+ Verify that nav header will be shown while program listing is disabled.
+ """
+ self.create_programs_config(program_listing_enabled=False)
+ view_path = reverse('account_settings')
+ response = self.client.get(path=view_path)
+
+ self.assertContains(response, '')
+
+ def test_commerce_order_detail(self):
+ with mock_get_orders():
+ order_detail = get_user_orders(self.user)
+
+ user_order = mock_get_orders.default_response['results'][0]
+ expected = [
+ {
+ 'number': user_order['number'],
+ 'price': user_order['total_excl_tax'],
+ 'title': user_order['lines'][0]['title'],
+ 'order_date': 'Jan 01, 2016',
+ 'receipt_url': '/commerce/checkout/receipt/?orderNum=' + user_order['number']
+ }
+ ]
+ self.assertEqual(order_detail, expected)
+
+ def test_commerce_order_detail_exception(self):
+ with mock_get_orders(exception=exceptions.HttpNotFoundError):
+ order_detail = get_user_orders(self.user)
+
+ self.assertEqual(order_detail, [])
+
+ def test_incomplete_order_detail(self):
+ response = {
+ 'results': [
+ factories.OrderFactory(
+ status='Incomplete',
+ lines=[
+ factories.OrderLineFactory(
+ product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory()])
+ )
+ ]
+ )
+ ]
+ }
+ with mock_get_orders(response=response):
+ order_detail = get_user_orders(self.user)
+
+ self.assertEqual(order_detail, [])
+
+ def test_honor_course_order_detail(self):
+ response = {
+ 'results': [
+ factories.OrderFactory(
+ lines=[
+ factories.OrderLineFactory(
+ product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory(
+ name='certificate_type',
+ value='honor'
+ )])
+ )
+ ]
+ )
+ ]
+ }
+ with mock_get_orders(response=response):
+ order_detail = get_user_orders(self.user)
+
+ self.assertEqual(order_detail, [])
+
@override_settings(SITE_NAME=settings.MICROSITE_LOGISTRATION_HOSTNAME)
class MicrositeLogistrationTests(TestCase):
diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py
index 6be8e300cb..0b71d8f7b3 100644
--- a/lms/djangoapps/student_account/views.py
+++ b/lms/djangoapps/student_account/views.py
@@ -3,28 +3,35 @@
import logging
import json
import urlparse
+from datetime import datetime
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
+from django.core.urlresolvers import reverse, resolve
from django.http import (
- HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
+ HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpRequest
)
from django.shortcuts import redirect
-from django.http import HttpRequest
-from django_countries import countries
-from django.core.urlresolvers import reverse, resolve
from django.utils.translation import ugettext as _
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods
-
-from lang_pref.api import released_languages, all_languages
+from django_countries import countries
from edxmako.shortcuts import render_to_response
+import pytz
+from commerce.models import CommerceConfiguration
from external_auth.login_and_register import (
login as external_auth_login,
register as external_auth_register
)
+from lang_pref.api import released_languages, all_languages
+from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
+from openedx.core.djangoapps.programs.models import ProgramsApiConfig
+from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site, get_value as get_themed_value
+from openedx.core.djangoapps.user_api.accounts.api import request_password_change
+from openedx.core.djangoapps.user_api.errors import UserNotFound
+from openedx.core.lib.edx_api_utils import get_edx_api_data
from student.models import UserProfile
from student.views import (
signin_user as old_login_view,
@@ -35,13 +42,14 @@ import third_party_auth
from third_party_auth import pipeline
from third_party_auth.decorators import xframe_allow_whitelisted
from util.bad_request_rate_limiter import BadRequestRateLimiter
-
from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site, get_value as get_themed_value
from openedx.core.djangoapps.user_api.accounts.api import request_password_change
from openedx.core.djangoapps.user_api.errors import UserNotFound
+from util.date_utils import strftime_localized
AUDIT_LOG = logging.getLogger("audit")
+log = logging.getLogger(__name__)
@require_http_methods(['GET'])
@@ -301,6 +309,50 @@ def _external_auth_intercept(request, mode):
return external_auth_register(request)
+def get_user_orders(user):
+ """Given a user, get the detail of all the orders from the Ecommerce service.
+
+ Arguments:
+ user (User): The user to authenticate as when requesting ecommerce.
+
+ Returns:
+ list of dict, representing orders returned by the Ecommerce service.
+ """
+ no_data = []
+ user_orders = []
+ allowed_course_modes = ['professional', 'verified', 'credit']
+ commerce_configuration = CommerceConfiguration.current()
+ user_query = {'username': user.username}
+
+ use_cache = commerce_configuration.is_cache_enabled
+ cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None
+ api = ecommerce_api_client(user)
+ commerce_user_orders = get_edx_api_data(
+ commerce_configuration, user, 'orders', api=api, querystring=user_query, cache_key=cache_key
+ )
+
+ for order in commerce_user_orders:
+ if order['status'].lower() == 'complete':
+ for line in order['lines']:
+ for attribute in line['product']['attribute_values']:
+ if attribute['name'] == 'certificate_type' and attribute['value'] in allowed_course_modes:
+ try:
+ date_placed = datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%SZ")
+ order_data = {
+ 'number': order['number'],
+ 'price': order['total_excl_tax'],
+ 'title': order['lines'][0]['title'],
+ 'order_date': strftime_localized(date_placed.replace(tzinfo=pytz.UTC), 'SHORT_DATE'),
+ 'receipt_url': commerce_configuration.receipt_page + order['number']
+ }
+ user_orders.append(order_data)
+ except KeyError:
+ log.exception('Invalid order structure: %r', order)
+ return no_data
+
+ return user_orders
+
+
@login_required
@require_http_methods(['GET'])
def account_settings(request):
@@ -394,6 +446,8 @@ def account_settings_context(request):
'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}),
'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}),
'disable_courseware_js': True,
+ 'show_program_listing': ProgramsApiConfig.current().show_program_listing,
+ 'order_history': get_user_orders(user)
}
if third_party_auth.is_enabled():
diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py
index 91f3a4e1d6..724506dd45 100644
--- a/lms/envs/bok_choy.py
+++ b/lms/envs/bok_choy.py
@@ -189,6 +189,10 @@ INSTALLED_APPS += ('coursewarehistoryextended',)
BADGING_BACKEND = 'lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend'
+# Configure the LMS to use our stub eCommerce implementation
+ECOMMERCE_API_URL = 'http://localhost:8043/api/v2/'
+ECOMMERCE_API_SIGNING_KEY = 'ecommerce-key'
+
#####################################################################
# Lastly, see if the developer has any local overrides.
try:
diff --git a/lms/static/js/spec/student_account/account_settings_factory_spec.js b/lms/static/js/spec/student_account/account_settings_factory_spec.js
index 87a90e01c2..713a237b2d 100644
--- a/lms/static/js/spec/student_account/account_settings_factory_spec.js
+++ b/lms/static/js/spec/student_account/account_settings_factory_spec.js
@@ -52,7 +52,7 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
var createAccountSettingsPage = function() {
var context = AccountSettingsPage(
- FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL, 'edX'
+ FIELDS_DATA, [], AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL, 'edX'
);
return context.accountSettingsView;
};
diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js
index 4a64b1a097..f3613a4e95 100644
--- a/lms/static/js/student_account/views/account_settings_factory.js
+++ b/lms/static/js/student_account/views/account_settings_factory.js
@@ -10,9 +10,18 @@
], function (gettext, $, _, Backbone, Logger, UserAccountModel, UserPreferencesModel,
AccountSettingsFieldViews, AccountSettingsView, StringUtils) {
- return function (fieldsData, authData, userAccountsApiUrl, userPreferencesApiUrl, accountUserId, platformName) {
+ return function (
+ fieldsData,
+ ordersHistoryData,
+ authData,
+ userAccountsApiUrl,
+ userPreferencesApiUrl,
+ accountUserId,
+ platformName
+ ) {
var accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData,
- accountsSectionData, accountSettingsView, showAccountSettingsPage, showLoadingError;
+ accountsSectionData, ordersSectionData, accountSettingsView, showAccountSettingsPage,
+ showLoadingError, orderNumber;
accountSettingsElement = $('.wrapper-account-settings');
@@ -170,13 +179,49 @@
}
];
+ ordersHistoryData.unshift(
+ {
+ 'title': gettext('ORDER NAME'),
+ 'order_date': gettext('ORDER PLACED'),
+ 'price': gettext('TOTAL'),
+ 'number': gettext('ORDER NUMBER')
+ }
+ );
+
+ ordersSectionData = [
+ {
+ title: gettext('My Orders'),
+ subtitle: StringUtils.interpolate(
+ gettext('This page contains information about orders that you have placed with {platform_name}.'), /* jshint ignore:line */
+ {platform_name: platformName}
+ ),
+ fields: _.map(ordersHistoryData, function(order) {
+ orderNumber = order.number;
+ if (orderNumber === 'ORDER NUMBER') {
+ orderNumber = 'orderId';
+ }
+ return {
+ 'view': new AccountSettingsFieldViews.OrderHistoryFieldView({
+ title: order.title,
+ totalPrice: order.price,
+ orderId: order.number,
+ orderDate: order.order_date,
+ receiptUrl: order.receipt_url,
+ valueAttribute: 'order-' + orderNumber
+ })
+ };
+ })
+ }
+ ];
+
accountSettingsView = new AccountSettingsView({
model: userAccountModel,
accountUserId: accountUserId,
el: accountSettingsElement,
tabSections: {
aboutTabSections: aboutSectionsData,
- accountsTabSections: accountsSectionData
+ accountsTabSections: accountsSectionData,
+ ordersTabSections: ordersSectionData
},
userPreferencesModel: userPreferencesModel
});
diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js
index 5031de7ab6..6cd797985a 100644
--- a/lms/static/js/student_account/views/account_settings_fields.js
+++ b/lms/static/js/student_account/views/account_settings_fields.js
@@ -11,7 +11,9 @@
'text!templates/fields/field_link_account.underscore',
'text!templates/fields/field_dropdown_account.underscore',
'text!templates/fields/field_social_link_account.underscore',
- 'edx-ui-toolkit/js/utils/string-utils'
+ 'text!templates/fields/field_order_history.underscore',
+ 'edx-ui-toolkit/js/utils/string-utils',
+ 'edx-ui-toolkit/js/utils/html-utils'
], function (
gettext, $, _, Backbone,
FieldViews,
@@ -20,7 +22,9 @@
field_link_account_template,
field_dropdown_account_template,
field_social_link_template,
- StringUtils
+ field_order_history_template,
+ StringUtils,
+ HtmlUtils
)
{
@@ -224,6 +228,30 @@
return this.indicators.success + gettext('Successfully unlinked.');
}
}),
+
+ OrderHistoryFieldView: FieldViews.ReadonlyFieldView.extend({
+ fieldType: 'orderHistory',
+ fieldTemplate: field_order_history_template,
+
+ initialize: function (options) {
+ this.options = options;
+ this._super(options);
+ this.template = HtmlUtils.template(this.fieldTemplate);
+ },
+
+ render: function () {
+ HtmlUtils.setHtml(this.$el, this.template({
+ title: this.options.title,
+ totalPrice: this.options.totalPrice,
+ orderId: this.options.orderId,
+ orderDate: this.options.orderDate,
+ receiptUrl: this.options.receiptUrl,
+ valueAttribute: this.options.valueAttribute
+ }));
+ this.delegateEvents();
+ return this;
+ }
+ })
};
return AccountSettingsFieldViews;
diff --git a/lms/static/js/student_account/views/account_settings_view.js b/lms/static/js/student_account/views/account_settings_view.js
index b3b80a5edd..bf55d19382 100644
--- a/lms/static/js/student_account/views/account_settings_view.js
+++ b/lms/static/js/student_account/views/account_settings_view.js
@@ -15,7 +15,8 @@
activeTab: 'aboutTabSections',
accountSettingsTabs: [
{name: 'aboutTabSections', id: 'about-tab', label: gettext('Account Information'), class: 'active'},
- {name: 'accountsTabSections', id: 'accounts-tab', label: gettext('Linked Accounts')}
+ {name: 'accountsTabSections', id: 'accounts-tab', label: gettext('Linked Accounts')},
+ {name: 'ordersTabSections', id: 'orders-tab', label: gettext('Order History')}
],
events: {
'click .account-nav-link': 'changeTab'
diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss
index 8a10d5845f..36ff69b2f4 100644
--- a/lms/static/sass/base/_variables.scss
+++ b/lms/static/sass/base/_variables.scss
@@ -213,6 +213,7 @@ $dark-gray1: rgb(74,74,74);
$light-gray1: rgb(242,242,242);
$light-gray2: rgb(171,171,171);
$light-gray3: rgb(249,249,249);
+$light-gray4: rgb(252,252,252);
$dark-gray2: rgb(151,151,151);
$blue1: rgb(74,144,226);
$blue2: rgb(0,161,229);
diff --git a/lms/static/sass/views/_account-settings.scss b/lms/static/sass/views/_account-settings.scss
index 88fff0224e..de6cde161c 100644
--- a/lms/static/sass/views/_account-settings.scss
+++ b/lms/static/sass/views/_account-settings.scss
@@ -193,6 +193,54 @@
}
}
+ .u-field-order {
+ display: flex;
+ align-items: center;
+ font-size: em(16);
+ color: $gray;
+ width: 100%;
+ padding-top: $baseline;
+ padding-bottom: $baseline;
+ line-height: normal;
+
+ span {
+ padding: $baseline;
+ }
+
+ .u-field-order-title {
+ @include float(left);
+ width: 26%;
+ font-size: em(20);
+ padding-left: ($baseline*2);
+ }
+
+ .u-field-order-value {
+ @include float(left);
+ width: 12%;
+ }
+
+ .u-field-order-date {
+ @include float(left);
+ width: 16%;
+ }
+
+ .u-field-order-link {
+ width: 15%;
+ padding: 0;
+
+ .u-field-link {
+ @extend %ui-clear-button;
+ @extend %btn-pl-default-base;
+ @include font-size(14);
+ border: 1px solid $blue;
+ color: $blue;
+ line-height: normal;
+ padding: 10px;
+ width: 110px;
+ }
+ }
+ }
+
.social-field-linked {
background: $m-gray-l4;
box-shadow: 0 1px 2px 1px $shadow-l2;
@@ -267,6 +315,42 @@
}
}
+ .u-field-orderHistory {
+ border-bottom: none;
+ border: 1px solid $m-gray-l4;
+ margin-bottom: $baseline;
+ padding: 0;
+
+ &:last-child {
+ border-bottom: 1px solid $m-gray-l4;
+ }
+
+ &:hover, &:focus {
+ background-color: $light-gray4;
+ }
+ }
+
+ .u-field-order-orderId {
+ border: none;
+ margin-top: $baseline;
+ margin-bottom: 0;
+ padding-bottom: 0;
+
+ &:hover, &:focus {
+ background-color: transparent;
+ }
+
+ .u-field-order {
+ font-weight: $font-semibold;
+ padding-top: 0;
+ padding-bottom: 0;
+
+ .u-field-order-title {
+ font-size: em(16);
+ }
+ }
+ }
+
.u-field-social {
border-bottom: none;
margin-right: 20px;
diff --git a/lms/templates/fields/field_order_history.underscore b/lms/templates/fields/field_order_history.underscore
new file mode 100644
index 0000000000..4d2daedce2
--- /dev/null
+++ b/lms/templates/fields/field_order_history.underscore
@@ -0,0 +1,11 @@
+ role="group" aria-labelledby="order-title-<%- orderId %>" <% } else { %> aria-hidden="true" <% } %>>
+
id="order-title-<%- orderId %>" <% } %>><%- title %>
+
Date Placed: <%- orderDate %>
+
Cost: <% if (!isNaN(parseFloat(totalPrice))) { %>$<% } %><%- totalPrice %>
+
Order Number: <%- orderId %>
+
+ <% if (receiptUrl) { %>
+ <%- gettext('Order Details') %> for <%- orderId %>
+ <% } %>
+
+
diff --git a/lms/templates/student_account/account_settings.html b/lms/templates/student_account/account_settings.html
index 5580a9b0ed..c8653c0a9b 100644
--- a/lms/templates/student_account/account_settings.html
+++ b/lms/templates/student_account/account_settings.html
@@ -32,12 +32,14 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str
<%block name="js_extra">
<%static:require_module module_name="js/student_account/views/account_settings_factory" class_name="AccountSettingsFactory">
- var fieldsData = ${ fields | n, dump_js_escaped_json };
- var authData = ${ auth | n, dump_js_escaped_json };
- var platformName = '${ static.get_platform_name() | n, js_escaped_string }';
+ var fieldsData = ${ fields | n, dump_js_escaped_json },
+ ordersHistoryData = ${ order_history | n, dump_js_escaped_json },
+ authData = ${ auth | n, dump_js_escaped_json },
+ platformName = '${ static.get_platform_name() | n, js_escaped_string }';
AccountSettingsFactory(
fieldsData,
+ ordersHistoryData,
authData,
'${ user_accounts_api_url | n, js_escaped_string }',
'${ user_preferences_api_url | n, js_escaped_string }',
diff --git a/lms/templates/student_account/account_settings_section.underscore b/lms/templates/student_account/account_settings_section.underscore
index e6f763d21d..0370c6ad51 100644
--- a/lms/templates/student_account/account_settings_section.underscore
+++ b/lms/templates/student_account/account_settings_section.underscore
@@ -1,7 +1,7 @@
<% _.each(sections, function(section) { %>
<% if (section.subtitle) { %>
-
+
<% } %>
diff --git a/openedx/core/lib/edx_api_utils.py b/openedx/core/lib/edx_api_utils.py
index b9f607aa1a..f9aff9c410 100644
--- a/openedx/core/lib/edx_api_utils.py
+++ b/openedx/core/lib/edx_api_utils.py
@@ -11,7 +11,8 @@ from openedx.core.lib.token_utils import get_id_token
log = logging.getLogger(__name__)
-def get_edx_api_data(api_config, user, resource, resource_id=None, querystring=None, cache_key=None):
+def get_edx_api_data(api_config, user, resource,
+ api=None, resource_id=None, querystring=None, cache_key=None):
"""GET data from an edX REST API.
DRY utility for handling caching and pagination.
@@ -22,6 +23,7 @@ def get_edx_api_data(api_config, user, resource, resource_id=None, querystring=N
resource (str): Name of the API resource being requested.
Keyword Arguments:
+ api (APIClient): API client to use for requesting data.
resource_id (int or str): Identifies a specific resource to be retrieved.
querystring (dict): Optional query string parameters.
cache_key (str): Where to cache retrieved data. The cache will be ignored if this is omitted
@@ -45,8 +47,9 @@ def get_edx_api_data(api_config, user, resource, resource_id=None, querystring=N
return cached
try:
- jwt = get_id_token(user, api_config.OAUTH2_CLIENT_NAME)
- api = EdxRestApiClient(api_config.internal_api_url, jwt=jwt)
+ if not api:
+ jwt = get_id_token(user, api_config.OAUTH2_CLIENT_NAME)
+ api = EdxRestApiClient(api_config.internal_api_url, jwt=jwt)
except: # pylint: disable=bare-except
log.exception('Failed to initialize the %s API client.', api_config.API_NAME)
return no_data
diff --git a/openedx/core/lib/tests/test_edx_api_utils.py b/openedx/core/lib/tests/test_edx_api_utils.py
index 8f5f1cee5c..df050ca1b4 100644
--- a/openedx/core/lib/tests/test_edx_api_utils.py
+++ b/openedx/core/lib/tests/test_edx_api_utils.py
@@ -3,12 +3,14 @@ import json
import unittest
from django.core.cache import cache
+from django.test.utils import override_settings
import httpretty
import mock
from nose.plugins.attrib import attr
from edx_oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL
+from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
@@ -17,6 +19,8 @@ from student.tests.factories import UserFactory
UTILITY_MODULE = 'openedx.core.lib.edx_api_utils'
+TEST_API_URL = 'http://www-internal.example.com/api'
+TEST_API_SIGNING_KEY = 'edx'
@attr('shard_2')
@@ -195,3 +199,15 @@ class TestGetEdxApiData(ProgramsApiConfigMixin, CacheIsolationTestCase):
self.assertTrue(mock_exception.called)
self.assertEqual(actual, [])
+
+ @override_settings(JWT_AUTH={'JWT_ISSUER': 'http://example.com/oauth', 'JWT_EXPIRATION': 30},
+ ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY, ECOMMERCE_API_URL=TEST_API_URL)
+ def test_client_passed(self):
+ """ Verify that when API client is passed edx_rest_api_client is not
+ used.
+ """
+ program_config = self.create_programs_config()
+ api = ecommerce_api_client(self.user)
+ with mock.patch('openedx.core.lib.edx_api_utils.EdxRestApiClient.__init__') as mock_init:
+ get_edx_api_data(program_config, self.user, 'orders', api=api)
+ self.assertFalse(mock_init.called)
diff --git a/pavelib/utils/envs.py b/pavelib/utils/envs.py
index 4fa56bdf57..11c27dc1fb 100644
--- a/pavelib/utils/envs.py
+++ b/pavelib/utils/envs.py
@@ -106,6 +106,11 @@ class Env(object):
'programs': {
'port': 8090,
'log': BOK_CHOY_LOG_DIR / "bok_choy_programs.log",
+ },
+
+ 'ecommerce': {
+ 'port': 8043,
+ 'log': BOK_CHOY_LOG_DIR / "bok_choy_ecommerce.log",
}
}