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, '