Merge pull request #12543 from edx/waheed/ecom-2361-complete-order-history-for-students
Complete Order History tab for students on account settings page
This commit is contained in:
64
common/djangoapps/terrain/stubs/ecommerce.py
Normal file
64
common/djangoapps/terrain/stubs/ecommerce.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
10
common/test/db_fixtures/commerce_config.json
Normal file
10
common/test/db_fixtures/commerce_config.json
Normal file
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"pk": 2,
|
||||
"model": "commerce.commerceconfiguration",
|
||||
"fields": {
|
||||
"enabled": 1,
|
||||
"change_date": "2016-04-21 10:19:32.034856"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
55
lms/djangoapps/commerce/tests/factories.py
Normal file
55
lms/djangoapps/commerce/tests/factories.py
Normal file
@@ -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()
|
||||
@@ -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/'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '<li class="tab-nav-item">')
|
||||
|
||||
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, '<li class="item nav-global-01">')
|
||||
|
||||
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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
lms/templates/fields/field_order_history.underscore
Normal file
11
lms/templates/fields/field_order_history.underscore
Normal file
@@ -0,0 +1,11 @@
|
||||
<div class="field u-field-order" <% if (receiptUrl) { %> role="group" aria-labelledby="order-title-<%- orderId %>" <% } else { %> aria-hidden="true" <% } %>>
|
||||
<span class="u-field-order-title" <% if (receiptUrl) { %> id="order-title-<%- orderId %>" <% } %>><%- title %></span>
|
||||
<span class="u-field-order-date"><span class="sr">Date Placed: </span><%- orderDate %></span>
|
||||
<span class="u-field-order-value u-field-order-price"><span class="sr">Cost: </span><% if (!isNaN(parseFloat(totalPrice))) { %>$<% } %><%- totalPrice %></span>
|
||||
<span class="u-field-order-value u-field-order-number"><span class="sr">Order Number: </span><%- orderId %></span>
|
||||
<span class="u-field-order-link">
|
||||
<% if (receiptUrl) { %>
|
||||
<a class="u-field-link" target="_blank" href="<%- receiptUrl %>"><%- gettext('Order Details') %><span class="sr"> for <%- orderId %></span></a>
|
||||
<% } %>
|
||||
</span>
|
||||
</div>
|
||||
@@ -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 }',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% _.each(sections, function(section) { %>
|
||||
<div class="section">
|
||||
<% if (section.subtitle) { %>
|
||||
<p id="header-subtitle-<%- activeTabName %>" class="account-settings-header-subtitle"><%- gettext(section.subtitle) %></p>
|
||||
<p id="header-subtitle-<%- activeTabName %>" class="account-settings-header-subtitle"><%- section.subtitle %></p>
|
||||
<% } %>
|
||||
<h3 class="section-header"><%- gettext(section.title) %></h3>
|
||||
<div class="account-settings-section-body">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user