Commit 62241b2b by Clinton Blackburn Committed by GitHub

Merge pull request #14408 from edx/clintonb/automated-refunds

Automating Refund Approvals
parents c0a64c2b 7c39978b
...@@ -49,7 +49,6 @@ import request_cache ...@@ -49,7 +49,6 @@ import request_cache
from certificates.models import GeneratedCertificate from certificates.models import GeneratedCertificate
from course_modes.models import CourseMode from course_modes.models import CourseMode
from enrollment.api import _default_course_mode from enrollment.api import _default_course_mode
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, ECOMMERCE_DATE_FORMAT
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, NoneToEmptyManager from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, NoneToEmptyManager
...@@ -1537,6 +1536,9 @@ class CourseEnrollment(models.Model): ...@@ -1537,6 +1536,9 @@ class CourseEnrollment(models.Model):
def refund_cutoff_date(self): def refund_cutoff_date(self):
""" Calculate and return the refund window end date. """ """ Calculate and return the refund window end date. """
# NOTE: This is here to avoid circular references
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, ECOMMERCE_DATE_FORMAT
try: try:
attribute = self.attributes.get(namespace='order', name='order_number') attribute = self.attributes.get(namespace='order', name='order_number')
except ObjectDoesNotExist: except ObjectDoesNotExist:
......
...@@ -30,7 +30,6 @@ from config_models.models import cache ...@@ -30,7 +30,6 @@ from config_models.models import cache
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
TEST_API_URL = 'http://www-internal.example.com/api' TEST_API_URL = 'http://www-internal.example.com/api'
TEST_API_SIGNING_KEY = 'edx'
JSON = 'application/json' JSON = 'application/json'
...@@ -131,7 +130,7 @@ class RefundableTest(SharedModuleStoreTestCase): ...@@ -131,7 +130,7 @@ class RefundableTest(SharedModuleStoreTestCase):
) )
@ddt.unpack @ddt.unpack
@httpretty.activate @httpretty.activate
@override_settings(ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY, ECOMMERCE_API_URL=TEST_API_URL) @override_settings(ECOMMERCE_API_URL=TEST_API_URL)
def test_refund_cutoff_date(self, order_date_delta, course_start_delta, expected_date_delta, days): def test_refund_cutoff_date(self, order_date_delta, course_start_delta, expected_date_delta, days):
""" """
Assert that the later date is used with the configurable refund period in calculating the returned cutoff date. Assert that the later date is used with the configurable refund period in calculating the returned cutoff date.
...@@ -172,7 +171,7 @@ class RefundableTest(SharedModuleStoreTestCase): ...@@ -172,7 +171,7 @@ class RefundableTest(SharedModuleStoreTestCase):
self.assertIsNone(self.enrollment.refund_cutoff_date()) self.assertIsNone(self.enrollment.refund_cutoff_date())
@httpretty.activate @httpretty.activate
@override_settings(ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY, ECOMMERCE_API_URL=TEST_API_URL) @override_settings(ECOMMERCE_API_URL=TEST_API_URL)
def test_multiple_refunds_dashbaord_page_error(self): def test_multiple_refunds_dashbaord_page_error(self):
""" Order with mutiple refunds will not throw 500 error when dashboard page will access.""" """ Order with mutiple refunds will not throw 500 error when dashboard page will access."""
now = datetime.now(pytz.UTC).replace(microsecond=0) now = datetime.now(pytz.UTC).replace(microsecond=0)
......
...@@ -18,7 +18,7 @@ from social import actions, exceptions ...@@ -18,7 +18,7 @@ from social import actions, exceptions
from social.apps.django_app import utils as social_utils from social.apps.django_app import utils as social_utils
from social.apps.django_app import views as social_views from social.apps.django_app import views as social_views
from lms.djangoapps.commerce.tests import TEST_API_URL, TEST_API_SIGNING_KEY from lms.djangoapps.commerce.tests import TEST_API_URL
from student import models as student_models from student import models as student_models
from student import views as student_views from student import views as student_views
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
...@@ -911,7 +911,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -911,7 +911,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# pylint: disable=test-inherits-tests, 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) @django_utils.override_settings(ECOMMERCE_API_URL=TEST_API_URL)
class Oauth2IntegrationTest(IntegrationTest): class Oauth2IntegrationTest(IntegrationTest):
"""Base test case for integration tests of Oauth2 providers.""" """Base test case for integration tests of Oauth2 providers."""
......
...@@ -17,7 +17,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -17,7 +17,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from commerce.constants import Messages from commerce.constants import Messages
from commerce.tests import TEST_BASKET_ID, TEST_ORDER_NUMBER, TEST_PAYMENT_DATA, TEST_API_URL, TEST_API_SIGNING_KEY from commerce.tests import TEST_BASKET_ID, TEST_ORDER_NUMBER, TEST_PAYMENT_DATA
from commerce.tests.mocks import mock_basket_order, mock_create_basket from commerce.tests.mocks import mock_basket_order, mock_create_basket
from commerce.tests.test_views import UserMixin from commerce.tests.test_views import UserMixin
from course_modes.models import CourseMode from course_modes.models import CourseMode
...@@ -39,7 +39,6 @@ UTM_COOKIE_CONTENTS = { ...@@ -39,7 +39,6 @@ UTM_COOKIE_CONTENTS = {
@attr(shard=1) @attr(shard=1)
@ddt.ddt @ddt.ddt
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase): class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase):
""" """
Tests for the commerce orders view. Tests for the commerce orders view.
...@@ -276,7 +275,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase) ...@@ -276,7 +275,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase)
# We should be enrolled in honor mode # We should be enrolled in honor mode
self._test_course_without_sku(enrollment_mode=CourseMode.HONOR) self._test_course_without_sku(enrollment_mode=CourseMode.HONOR)
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None) @override_settings(ECOMMERCE_API_URL=None)
def test_ecommerce_service_not_configured(self): def test_ecommerce_service_not_configured(self):
""" """
If the E-Commerce Service is not configured, the view should enroll the user. If the E-Commerce Service is not configured, the view should enroll the user.
...@@ -313,7 +312,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase) ...@@ -313,7 +312,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase)
""" Verifies that the view behaves appropriately when the course only has a professional mode. """ """ Verifies that the view behaves appropriately when the course only has a professional mode. """
self.assertProfessionalModeBypassed() self.assertProfessionalModeBypassed()
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None) @override_settings(ECOMMERCE_API_URL=None)
def test_professional_mode_only_and_ecommerce_service_not_configured(self): def test_professional_mode_only_and_ecommerce_service_not_configured(self):
""" """
Verifies that the view behaves appropriately when the course only has a professional mode and Verifies that the view behaves appropriately when the course only has a professional mode and
...@@ -390,7 +389,6 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase) ...@@ -390,7 +389,6 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase)
@attr(shard=1) @attr(shard=1)
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
class BasketOrderViewTests(UserMixin, TestCase): class BasketOrderViewTests(UserMixin, TestCase):
""" Tests for the basket order view. """ """ Tests for the basket order view. """
view_name = 'commerce_api:v0:baskets:retrieve_order' view_name = 'commerce_api:v0:baskets:retrieve_order'
......
...@@ -17,7 +17,6 @@ from rest_framework.utils.encoders import JSONEncoder ...@@ -17,7 +17,6 @@ from rest_framework.utils.encoders import JSONEncoder
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from commerce.tests import TEST_API_URL, TEST_API_SIGNING_KEY
from commerce.tests.mocks import mock_order_endpoint from commerce.tests.mocks import mock_order_endpoint
from commerce.tests.test_views import UserMixin from commerce.tests.test_views import UserMixin
from course_modes.models import CourseMode from course_modes.models import CourseMode
...@@ -391,7 +390,6 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase) ...@@ -391,7 +390,6 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
@attr(shard=1) @attr(shard=1)
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
class OrderViewTests(UserMixin, TestCase): class OrderViewTests(UserMixin, TestCase):
""" Tests for the basket order view. """ """ Tests for the basket order view. """
view_name = 'commerce_api:v1:orders:detail' view_name = 'commerce_api:v1:orders:detail'
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('commerce', '0004_auto_20160531_0950'),
]
operations = [
migrations.AddField(
model_name='commerceconfiguration',
name='enable_automatic_refund_approval',
field=models.BooleanField(default=True, help_text='Automatically approve valid refund requests, without manual processing'),
),
]
...@@ -39,6 +39,10 @@ class CommerceConfiguration(ConfigurationModel): ...@@ -39,6 +39,10 @@ class CommerceConfiguration(ConfigurationModel):
default=DEFAULT_RECEIPT_PAGE_URL, default=DEFAULT_RECEIPT_PAGE_URL,
help_text=_('Path to order receipt page.') help_text=_('Path to order receipt page.')
) )
enable_automatic_refund_approval = models.BooleanField(
default=True,
help_text=_('Automatically approve valid refund requests, without manual processing')
)
def __unicode__(self): def __unicode__(self):
return "Commerce configuration" return "Commerce configuration"
......
...@@ -9,23 +9,24 @@ from urlparse import urljoin ...@@ -9,23 +9,24 @@ from urlparse import urljoin
import requests import requests
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from edx_rest_api_client.exceptions import HttpClientError
from request_cache.middleware import RequestCache
from student.models import UNENROLL_DONE
from commerce.models import CommerceConfiguration
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers
from request_cache.middleware import RequestCache
from student.models import UNENROLL_DONE
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# pylint: disable=unused-argument
@receiver(UNENROLL_DONE) @receiver(UNENROLL_DONE)
def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False, def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False, **kwargs):
**kwargs): # pylint: disable=unused-argument
""" """
Signal receiver for unenrollments, used to automatically initiate refunds Signal receiver for unenrollments, used to automatically initiate refunds
when applicable. when applicable.
...@@ -40,12 +41,12 @@ def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False, ...@@ -40,12 +41,12 @@ def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False,
request_user = get_request_user() or course_enrollment.user request_user = get_request_user() or course_enrollment.user
if isinstance(request_user, AnonymousUser): if isinstance(request_user, AnonymousUser):
# Assume the request was initiated via server-to-server # Assume the request was initiated via server-to-server
# api call (presumably Otto). In this case we cannot # API call (presumably Otto). In this case we cannot
# construct a client to call Otto back anyway, because # construct a client to call Otto back anyway, because
# the client does not work anonymously, and furthermore, # the client does not work anonymously, and furthermore,
# there's certainly no need to inform Otto about this request. # there's certainly no need to inform Otto about this request.
return return
refund_seat(course_enrollment, request_user) refund_seat(course_enrollment)
except: # pylint: disable=bare-except except: # pylint: disable=bare-except
# don't assume the signal was fired with `send_robust`. # don't assume the signal was fired with `send_robust`.
# avoid blowing up other signal handlers by gracefully # avoid blowing up other signal handlers by gracefully
...@@ -69,57 +70,56 @@ def get_request_user(): ...@@ -69,57 +70,56 @@ def get_request_user():
return getattr(request, 'user', None) return getattr(request, 'user', None)
def refund_seat(course_enrollment, request_user): def refund_seat(course_enrollment):
""" """
Attempt to initiate a refund for any orders associated with the seat being Attempt to initiate a refund for any orders associated with the seat being unenrolled, using the commerce service.
unenrolled, using the commerce service.
Arguments: Arguments:
course_enrollment (CourseEnrollment): a student enrollment course_enrollment (CourseEnrollment): a student enrollment
request_user: the user as whom to authenticate to the commerce service
when attempting to initiate the refund.
Returns: Returns:
A list of the external service's IDs for any refunds that were initiated A list of the external service's IDs for any refunds that were initiated
(may be empty). (may be empty).
Raises: Raises:
exceptions.SlumberBaseException: for any unhandled HTTP error during exceptions.SlumberBaseException: for any unhandled HTTP error during communication with the E-Commerce Service.
communication with the commerce service. exceptions.Timeout: if the attempt to reach the commerce service timed out.
exceptions.Timeout: if the attempt to reach the commerce service timed
out.
""" """
User = get_user_model() # pylint:disable=invalid-name
course_key_str = unicode(course_enrollment.course_id) course_key_str = unicode(course_enrollment.course_id)
unenrolled_user = course_enrollment.user enrollee = course_enrollment.user
try: service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
refund_ids = ecommerce_api_client(request_user or unenrolled_user).refunds.post( api_client = ecommerce_api_client(service_user)
{'course_id': course_key_str, 'username': unenrolled_user.username}
) log.info('Attempting to create a refund for user [%s], course [%s]...', enrollee.id, course_key_str)
except HttpClientError, exc:
if exc.response.status_code == 403 and request_user != unenrolled_user: refund_ids = api_client.refunds.post({'course_id': course_key_str, 'username': enrollee.username})
# this is a known limitation; commerce service does not presently
# support the case of a non-superusers initiating a refund on
# behalf of another user.
log.warning("User [%s] was not authorized to initiate a refund for user [%s] "
"upon unenrollment from course [%s]", request_user.id, unenrolled_user.id, course_key_str)
return []
else:
# no other error is anticipated, so re-raise the Exception
raise exc
if refund_ids: if refund_ids:
# at least one refundable order was found. log.info('Refund successfully opened for user [%s], course [%s]: %r', enrollee.id, course_key_str, refund_ids)
log.info(
"Refund successfully opened for user [%s], course [%s]: %r", config = CommerceConfiguration.current()
unenrolled_user.id,
course_key_str, if config.enable_automatic_refund_approval:
refund_ids, refunds_requiring_approval = []
)
for refund_id in refund_ids:
try:
# NOTE: Approve payment only because the user has already been unenrolled. Additionally, this
# ensures we don't tie up an additional web worker when the E-Commerce Service tries to unenroll
# the learner
api_client.refunds(refund_id).process.put({'action': 'approve_payment_only'})
log.info('Refund [%d] successfully approved.', refund_id)
except: # pylint: disable=bare-except
log.exception('Failed to automatically approve refund [%d]!', refund_id)
refunds_requiring_approval.append(refund_id)
else:
refunds_requiring_approval = refund_ids
if refunds_requiring_approval:
# XCOM-371: this is a temporary measure to suppress refund-related email # XCOM-371: this is a temporary measure to suppress refund-related email
# notifications to students and support@) for free enrollments. This # notifications to students and support for free enrollments. This
# condition should be removed when the CourseEnrollment.refundable() logic # condition should be removed when the CourseEnrollment.refundable() logic
# is updated to be more correct, or when we implement better handling (and # is updated to be more correct, or when we implement better handling (and
# notifications) in Otto for handling reversal of $0 transactions. # notifications) in Otto for handling reversal of $0 transactions.
...@@ -127,20 +127,19 @@ def refund_seat(course_enrollment, request_user): ...@@ -127,20 +127,19 @@ def refund_seat(course_enrollment, request_user):
# 'verified' is the only enrollment mode that should presently # 'verified' is the only enrollment mode that should presently
# result in opening a refund request. # result in opening a refund request.
log.info( log.info(
"Skipping refund email notification for non-verified mode for user [%s], course [%s], mode: [%s]", 'Skipping refund email notification for non-verified mode for user [%s], course [%s], mode: [%s]',
course_enrollment.user.id, course_enrollment.user.id,
course_enrollment.course_id, course_enrollment.course_id,
course_enrollment.mode, course_enrollment.mode,
) )
else: else:
try: try:
send_refund_notification(course_enrollment, refund_ids) send_refund_notification(course_enrollment, refunds_requiring_approval)
except: # pylint: disable=bare-except except: # pylint: disable=bare-except
# don't break, just log a warning # don't break, just log a warning
log.warning("Could not send email notification for refund.", exc_info=True) log.warning('Could not send email notification for refund.', exc_info=True)
else: else:
# no refundable orders were found. log.info('No refund opened for user [%s], course [%s]', enrollee.id, course_key_str)
log.debug("No refund opened for user [%s], course [%s]", unenrolled_user.id, course_key_str)
return refund_ids return refund_ids
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" Commerce app tests package. """ """ Commerce app tests package. """
import datetime
import json
import httpretty
import mock
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings
from freezegun import freeze_time from freezegun import freeze_time
import httpretty
import jwt
import mock
from edx_rest_api_client import auth
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.lib.token_utils import JwtBuilder
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
JSON = 'application/json' JSON = 'application/json'
TEST_PUBLIC_URL_ROOT = 'http://www.example.com' TEST_PUBLIC_URL_ROOT = 'http://www.example.com'
TEST_API_URL = 'http://www-internal.example.com/api' TEST_API_URL = 'http://www-internal.example.com/api'
TEST_API_SIGNING_KEY = 'edx'
TEST_BASKET_ID = 7 TEST_BASKET_ID = 7
TEST_ORDER_NUMBER = '100004' TEST_ORDER_NUMBER = '100004'
TEST_PAYMENT_DATA = { TEST_PAYMENT_DATA = {
...@@ -29,33 +23,27 @@ TEST_PAYMENT_DATA = { ...@@ -29,33 +23,27 @@ TEST_PAYMENT_DATA = {
} }
@override_settings(ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY, ECOMMERCE_API_URL=TEST_API_URL)
class EdxRestApiClientTest(TestCase): class EdxRestApiClientTest(TestCase):
""" Tests to ensure the client is initialized properly. """ """ Tests to ensure the client is initialized properly. """
TEST_USER_EMAIL = 'test@example.com'
TEST_CLIENT_ID = 'test-client-id' TEST_CLIENT_ID = 'test-client-id'
def setUp(self): def setUp(self):
super(EdxRestApiClientTest, self).setUp() super(EdxRestApiClientTest, self).setUp()
self.user = UserFactory() self.user = UserFactory()
self.user.email = self.TEST_USER_EMAIL
self.user.save() # pylint: disable=no-member
@httpretty.activate @httpretty.activate
@freeze_time('2015-7-2') @freeze_time('2015-7-2')
@override_settings(JWT_AUTH={'JWT_ISSUER': 'http://example.com/oauth', 'JWT_EXPIRATION': 30})
def test_tracking_context(self): def test_tracking_context(self):
""" """
Ensure the tracking context is set up in the api client correctly and Ensure the tracking context is set up in the api client correctly and
automatically. automatically.
""" """
# fake an ecommerce api request. # fake an E-Commerce API request.
httpretty.register_uri( httpretty.register_uri(
httpretty.POST, httpretty.POST,
'{}/baskets/1/'.format(TEST_API_URL), '{}/baskets/1/'.format(settings.ECOMMERCE_API_URL.strip('/')),
status=200, body='{}', status=200, body='{}',
adding_headers={'Content-Type': JSON} adding_headers={'Content-Type': JSON}
) )
...@@ -65,23 +53,18 @@ class EdxRestApiClientTest(TestCase): ...@@ -65,23 +53,18 @@ class EdxRestApiClientTest(TestCase):
with mock.patch('openedx.core.djangoapps.commerce.utils.tracker.get_tracker', return_value=mock_tracker): with mock.patch('openedx.core.djangoapps.commerce.utils.tracker.get_tracker', return_value=mock_tracker):
ecommerce_api_client(self.user).baskets(1).post() ecommerce_api_client(self.user).baskets(1).post()
# make sure the request's JWT token payload included correct tracking context values. # Verify the JWT includes the tracking context for the user
actual_header = httpretty.last_request().headers['Authorization'] actual_header = httpretty.last_request().headers['Authorization']
expected_payload = {
'username': self.user.username, claims = {
'full_name': self.user.profile.name,
'email': self.user.email,
'iss': settings.JWT_AUTH['JWT_ISSUER'],
'iat': datetime.datetime.utcnow(),
'exp': datetime.datetime.utcnow() + datetime.timedelta(seconds=settings.JWT_AUTH['JWT_EXPIRATION']),
'tracking_context': { 'tracking_context': {
'lms_user_id': self.user.id, # pylint: disable=no-member 'lms_user_id': self.user.id, # pylint: disable=no-member
'lms_client_id': self.TEST_CLIENT_ID, 'lms_client_id': self.TEST_CLIENT_ID,
'lms_ip': '127.0.0.1', 'lms_ip': '127.0.0.1',
},
} }
}
expected_header = 'JWT {}'.format(jwt.encode(expected_payload, TEST_API_SIGNING_KEY)) expected_jwt = JwtBuilder(self.user).build_token(['email', 'profile'], additional_claims=claims)
expected_header = 'JWT {}'.format(expected_jwt)
self.assertEqual(actual_header, expected_header) self.assertEqual(actual_header, expected_header)
@httpretty.activate @httpretty.activate
...@@ -95,19 +78,9 @@ class EdxRestApiClientTest(TestCase): ...@@ -95,19 +78,9 @@ class EdxRestApiClientTest(TestCase):
expected_content = '{"result": "Préparatoire"}' expected_content = '{"result": "Préparatoire"}'
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'{}/baskets/1/order/'.format(TEST_API_URL), '{}/baskets/1/order/'.format(settings.ECOMMERCE_API_URL.strip('/')),
status=200, body=expected_content, status=200, body=expected_content,
adding_headers={'Content-Type': JSON}, adding_headers={'Content-Type': JSON},
) )
actual_object = ecommerce_api_client(self.user).baskets(1).order.get() actual_object = ecommerce_api_client(self.user).baskets(1).order.get()
self.assertEqual(actual_object, {u"result": u"Préparatoire"}) self.assertEqual(actual_object, {u"result": u"Préparatoire"})
def test_client_with_user_without_profile(self):
"""
Verify client initialize successfully for users having no profile.
"""
worker = User.objects.create_user(username='test_worker', email='test@example.com')
api_client = ecommerce_api_client(worker)
self.assertEqual(api_client._store['session'].auth.__dict__['username'], worker.username) # pylint: disable=protected-access
self.assertIsNone(api_client._store['session'].auth.__dict__['full_name']) # pylint: disable=protected-access
...@@ -2,11 +2,14 @@ ...@@ -2,11 +2,14 @@
import json import json
import httpretty import httpretty
from django.conf import settings
from commerce.tests import TEST_API_URL, factories from commerce.tests import factories
class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name # pylint: disable=invalid-name
class mock_ecommerce_api_endpoint(object):
""" """
Base class for contextmanagers used to mock calls to api endpoints. Base class for contextmanagers used to mock calls to api endpoints.
...@@ -21,7 +24,9 @@ class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name ...@@ -21,7 +24,9 @@ class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name
# override this in subclasses, using one of httpretty's method constants # override this in subclasses, using one of httpretty's method constants
method = None method = None
def __init__(self, response=None, status=200, expect_called=True, exception=None): host = settings.ECOMMERCE_API_URL.strip('/')
def __init__(self, response=None, status=200, expect_called=True, exception=None, reset_on_exit=True):
""" """
Keyword Arguments: Keyword Arguments:
response: a JSON-serializable Python type representing the desired response body. response: a JSON-serializable Python type representing the desired response body.
...@@ -29,17 +34,28 @@ class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name ...@@ -29,17 +34,28 @@ class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name
expect_called: a boolean indicating whether an API request was expected; set expect_called: a boolean indicating whether an API request was expected; set
to False if we should ensure that no request arrived. to False if we should ensure that no request arrived.
exception: raise this exception instead of returning an HTTP response when called. exception: raise this exception instead of returning an HTTP response when called.
reset_on_exit (bool): Indicates if `httpretty` should be reset after the decorator exits.
""" """
self.response = response or self.default_response self.response = response or self.default_response
self.status = status self.status = status
self.expect_called = expect_called self.expect_called = expect_called
self.exception = exception self.exception = exception
self.reset_on_exit = reset_on_exit
def get_uri(self): def get_uri(self):
""" """
Return the uri to register with httpretty for this contextmanager. Returns the uri to register with httpretty for this contextmanager.
"""
return self.host + '/' + self.get_path().lstrip('/')
def get_path(self):
"""
Returns the path of the URI to register with httpretty for this contextmanager.
Subclasses must override this method. Subclasses must override this method.
Returns:
str
""" """
raise NotImplementedError raise NotImplementedError
...@@ -48,7 +64,6 @@ class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name ...@@ -48,7 +64,6 @@ class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name
raise self.exception # pylint: disable=raising-bad-type raise self.exception # pylint: disable=raising-bad-type
def __enter__(self): def __enter__(self):
httpretty.reset()
httpretty.enable() httpretty.enable()
httpretty.register_uri( httpretty.register_uri(
self.method, self.method,
...@@ -61,9 +76,11 @@ class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name ...@@ -61,9 +76,11 @@ class mock_ecommerce_api_endpoint(object): # pylint: disable=invalid-name
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
assert self.expect_called == (httpretty.last_request().headers != {}) assert self.expect_called == (httpretty.last_request().headers != {})
httpretty.disable() httpretty.disable()
if self.reset_on_exit:
httpretty.reset()
class mock_create_basket(mock_ecommerce_api_endpoint): # pylint: disable=invalid-name class mock_create_basket(mock_ecommerce_api_endpoint):
""" Mocks calls to E-Commerce API client basket creation method. """ """ Mocks calls to E-Commerce API client basket creation method. """
default_response = { default_response = {
...@@ -77,11 +94,11 @@ class mock_create_basket(mock_ecommerce_api_endpoint): # pylint: disable=invali ...@@ -77,11 +94,11 @@ class mock_create_basket(mock_ecommerce_api_endpoint): # pylint: disable=invali
} }
method = httpretty.POST method = httpretty.POST
def get_uri(self): def get_path(self):
return TEST_API_URL + '/baskets/' return '/baskets/'
class mock_basket_order(mock_ecommerce_api_endpoint): # pylint: disable=invalid-name class mock_basket_order(mock_ecommerce_api_endpoint):
""" Mocks calls to E-Commerce API client basket order method. """ """ Mocks calls to E-Commerce API client basket order method. """
default_response = {'number': 1} default_response = {'number': 1}
...@@ -91,21 +108,35 @@ class mock_basket_order(mock_ecommerce_api_endpoint): # pylint: disable=invalid ...@@ -91,21 +108,35 @@ class mock_basket_order(mock_ecommerce_api_endpoint): # pylint: disable=invalid
super(mock_basket_order, self).__init__(**kwargs) super(mock_basket_order, self).__init__(**kwargs)
self.basket_id = basket_id self.basket_id = basket_id
def get_uri(self): def get_path(self):
return TEST_API_URL + '/baskets/{}/order/'.format(self.basket_id) return '/baskets/{}/order/'.format(self.basket_id)
class mock_create_refund(mock_ecommerce_api_endpoint): # pylint: disable=invalid-name class mock_create_refund(mock_ecommerce_api_endpoint):
""" Mocks calls to E-Commerce API client refund creation method. """ """ Mocks calls to E-Commerce API client refund creation method. """
default_response = [] default_response = []
method = httpretty.POST method = httpretty.POST
def get_uri(self): def get_path(self):
return TEST_API_URL + '/refunds/' return '/refunds/'
class mock_process_refund(mock_ecommerce_api_endpoint):
""" Mocks calls to E-Commerce API client refund process method. """
default_response = []
method = httpretty.PUT
def __init__(self, refund_id, **kwargs):
super(mock_process_refund, self).__init__(**kwargs)
self.refund_id = refund_id
class mock_order_endpoint(mock_ecommerce_api_endpoint): # pylint: disable=invalid-name def get_path(self):
return '/refunds/{}/process/'.format(self.refund_id)
class mock_order_endpoint(mock_ecommerce_api_endpoint):
""" Mocks calls to E-Commerce API client basket order method. """ """ Mocks calls to E-Commerce API client basket order method. """
default_response = {'number': 'EDX-100001'} default_response = {'number': 'EDX-100001'}
...@@ -115,11 +146,11 @@ class mock_order_endpoint(mock_ecommerce_api_endpoint): # pylint: disable=inval ...@@ -115,11 +146,11 @@ class mock_order_endpoint(mock_ecommerce_api_endpoint): # pylint: disable=inval
super(mock_order_endpoint, self).__init__(**kwargs) super(mock_order_endpoint, self).__init__(**kwargs)
self.order_number = order_number self.order_number = order_number
def get_uri(self): def get_path(self):
return TEST_API_URL + '/orders/{}/'.format(self.order_number) return '/orders/{}/'.format(self.order_number)
class mock_get_orders(mock_ecommerce_api_endpoint): # pylint: disable=invalid-name class mock_get_orders(mock_ecommerce_api_endpoint):
""" Mocks calls to E-Commerce API client order get method. """ """ Mocks calls to E-Commerce API client order get method. """
default_response = { default_response = {
...@@ -138,5 +169,5 @@ class mock_get_orders(mock_ecommerce_api_endpoint): # pylint: disable=invalid-n ...@@ -138,5 +169,5 @@ class mock_get_orders(mock_ecommerce_api_endpoint): # pylint: disable=invalid-n
} }
method = httpretty.GET method = httpretty.GET
def get_uri(self): def get_path(self):
return TEST_API_URL + '/orders/' return '/orders/'
...@@ -9,21 +9,22 @@ import json ...@@ -9,21 +9,22 @@ import json
from urlparse import urljoin from urlparse import urljoin
import ddt import ddt
import httpretty
import mock
from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
import httpretty
import mock
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from requests import Timeout from requests import Timeout
from commerce.models import CommerceConfiguration
from commerce.signals import send_refund_notification, generate_refund_notification_body, create_zendesk_ticket
from commerce.tests import JSON
from commerce.tests.mocks import mock_create_refund, mock_process_refund
from course_modes.models import CourseMode
from student.models import UNENROLL_DONE from student.models import UNENROLL_DONE
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from commerce.signals import (refund_seat, send_refund_notification, generate_refund_notification_body,
create_zendesk_ticket)
from commerce.tests import TEST_PUBLIC_URL_ROOT, TEST_API_URL, TEST_API_SIGNING_KEY, JSON
from commerce.tests.mocks import mock_create_refund
from course_modes.models import CourseMode
ZENDESK_URL = 'http://zendesk.example.com/' ZENDESK_URL = 'http://zendesk.example.com/'
ZENDESK_USER = 'test@example.com' ZENDESK_USER = 'test@example.com'
...@@ -31,11 +32,7 @@ ZENDESK_API_KEY = 'abc123' ...@@ -31,11 +32,7 @@ ZENDESK_API_KEY = 'abc123'
@ddt.ddt @ddt.ddt
@override_settings( @override_settings(ZENDESK_URL=ZENDESK_URL, ZENDESK_USER=ZENDESK_USER, ZENDESK_API_KEY=ZENDESK_API_KEY)
ECOMMERCE_PUBLIC_URL_ROOT=TEST_PUBLIC_URL_ROOT,
ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY,
ZENDESK_URL=ZENDESK_URL, ZENDESK_USER=ZENDESK_USER, ZENDESK_API_KEY=ZENDESK_API_KEY
)
class TestRefundSignal(TestCase): class TestRefundSignal(TestCase):
""" """
Exercises logic triggered by the UNENROLL_DONE signal. Exercises logic triggered by the UNENROLL_DONE signal.
...@@ -43,6 +40,10 @@ class TestRefundSignal(TestCase): ...@@ -43,6 +40,10 @@ class TestRefundSignal(TestCase):
def setUp(self): def setUp(self):
super(TestRefundSignal, self).setUp() super(TestRefundSignal, self).setUp()
# Ensure the E-Commerce service user exists
UserFactory(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME, is_staff=True)
self.requester = UserFactory(username="test-requester") self.requester = UserFactory(username="test-requester")
self.student = UserFactory( self.student = UserFactory(
username="test-student", username="test-student",
...@@ -55,6 +56,10 @@ class TestRefundSignal(TestCase): ...@@ -55,6 +56,10 @@ class TestRefundSignal(TestCase):
) )
self.course_enrollment.refundable = mock.Mock(return_value=True) self.course_enrollment.refundable = mock.Mock(return_value=True)
self.config = CommerceConfiguration.current()
self.config.enable_automatic_refund_approval = True
self.config.save()
def send_signal(self, skip_refund=False): def send_signal(self, skip_refund=False):
""" """
DRY helper: emit the UNENROLL_DONE signal, as is done in DRY helper: emit the UNENROLL_DONE signal, as is done in
...@@ -65,7 +70,6 @@ class TestRefundSignal(TestCase): ...@@ -65,7 +70,6 @@ class TestRefundSignal(TestCase):
@override_settings( @override_settings(
ECOMMERCE_PUBLIC_URL_ROOT=None, ECOMMERCE_PUBLIC_URL_ROOT=None,
ECOMMERCE_API_URL=None, ECOMMERCE_API_URL=None,
ECOMMERCE_API_SIGNING_KEY=None,
) )
def test_no_service(self): def test_no_service(self):
""" """
...@@ -88,7 +92,7 @@ class TestRefundSignal(TestCase): ...@@ -88,7 +92,7 @@ class TestRefundSignal(TestCase):
""" """
self.send_signal() self.send_signal()
self.assertTrue(mock_refund_seat.called) self.assertTrue(mock_refund_seat.called)
self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment, self.student)) self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment,))
# if skip_refund is set to True in the signal, we should not try to initiate a refund. # if skip_refund is set to True in the signal, we should not try to initiate a refund.
mock_refund_seat.reset_mock() mock_refund_seat.reset_mock()
...@@ -110,21 +114,21 @@ class TestRefundSignal(TestCase): ...@@ -110,21 +114,21 @@ class TestRefundSignal(TestCase):
# no HTTP request/user: auth to commerce service as the unenrolled student. # no HTTP request/user: auth to commerce service as the unenrolled student.
self.send_signal() self.send_signal()
self.assertTrue(mock_refund_seat.called) self.assertTrue(mock_refund_seat.called)
self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment, self.student)) self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment,))
# HTTP user is the student: auth to commerce service as the unenrolled student. # HTTP user is the student: auth to commerce service as the unenrolled student.
mock_get_request_user.return_value = self.student mock_get_request_user.return_value = self.student
mock_refund_seat.reset_mock() mock_refund_seat.reset_mock()
self.send_signal() self.send_signal()
self.assertTrue(mock_refund_seat.called) self.assertTrue(mock_refund_seat.called)
self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment, self.student)) self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment,))
# HTTP user is another user: auth to commerce service as the requester. # HTTP user is another user: auth to commerce service as the requester.
mock_get_request_user.return_value = self.requester mock_get_request_user.return_value = self.requester
mock_refund_seat.reset_mock() mock_refund_seat.reset_mock()
self.send_signal() self.send_signal()
self.assertTrue(mock_refund_seat.called) self.assertTrue(mock_refund_seat.called)
self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment, self.requester)) self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment,))
# HTTP user is another server (AnonymousUser): do not try to initiate a refund at all. # HTTP user is another server (AnonymousUser): do not try to initiate a refund at all.
mock_get_request_user.return_value = AnonymousUser() mock_get_request_user.return_value = AnonymousUser()
...@@ -132,15 +136,6 @@ class TestRefundSignal(TestCase): ...@@ -132,15 +136,6 @@ class TestRefundSignal(TestCase):
self.send_signal() self.send_signal()
self.assertFalse(mock_refund_seat.called) self.assertFalse(mock_refund_seat.called)
@mock.patch('commerce.signals.log.warning')
def test_not_authorized_warning(self, mock_log_warning):
"""
Ensure that expected authorization issues are logged as warnings.
"""
with mock_create_refund(status=403):
refund_seat(self.course_enrollment, UserFactory())
self.assertTrue(mock_log_warning.called)
@mock.patch('commerce.signals.log.exception') @mock.patch('commerce.signals.log.exception')
def test_error_logging(self, mock_log_exception): def test_error_logging(self, mock_log_exception):
""" """
...@@ -152,14 +147,48 @@ class TestRefundSignal(TestCase): ...@@ -152,14 +147,48 @@ class TestRefundSignal(TestCase):
self.assertTrue(mock_log_exception.called) self.assertTrue(mock_log_exception.called)
@mock.patch('commerce.signals.send_refund_notification') @mock.patch('commerce.signals.send_refund_notification')
def test_notification(self, mock_send_notification): def test_notification_when_approval_fails(self, mock_send_notification):
""" """
Ensure the notification function is triggered when refunds are Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
initiated
""" """
with mock_create_refund(status=200, response=[1, 2, 3]): refund_id = 1
failed_refund_id = 2
with mock_create_refund(status=201, response=[refund_id, failed_refund_id]):
with mock_process_refund(refund_id, reset_on_exit=False):
with mock_process_refund(failed_refund_id, status=500, reset_on_exit=False):
self.send_signal() self.send_signal()
self.assertTrue(mock_send_notification.called) self.assertTrue(mock_send_notification.called)
mock_send_notification.assert_called_with(self.course_enrollment, [failed_refund_id])
@mock.patch('commerce.signals.send_refund_notification')
def test_notification_if_automatic_approval_disabled(self, mock_send_notification):
"""
Ensure the notification is always sent if the automatic approval functionality is disabled.
"""
refund_id = 1
self.config.enable_automatic_refund_approval = False
self.config.save()
with mock_create_refund(status=201, response=[refund_id]):
self.send_signal()
self.assertTrue(mock_send_notification.called)
mock_send_notification.assert_called_with(self.course_enrollment, [refund_id])
@mock.patch('commerce.signals.send_refund_notification')
def test_no_notification_after_approval(self, mock_send_notification):
"""
Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
"""
refund_id = 1
with mock_create_refund(status=201, response=[refund_id]):
with mock_process_refund(refund_id, reset_on_exit=False):
self.send_signal()
self.assertFalse(mock_send_notification.called)
last_request = httpretty.last_request()
self.assertDictEqual(json.loads(last_request.body), {'action': 'approve_payment_only'})
@mock.patch('commerce.signals.send_refund_notification') @mock.patch('commerce.signals.send_refund_notification')
def test_notification_no_refund(self, mock_send_notification): def test_notification_no_refund(self, mock_send_notification):
......
...@@ -32,7 +32,7 @@ from provider.oauth2.models import ( ...@@ -32,7 +32,7 @@ from provider.oauth2.models import (
from testfixtures import LogCapture from testfixtures import LogCapture
from commerce.models import CommerceConfiguration from commerce.models import CommerceConfiguration
from commerce.tests import TEST_API_URL, TEST_API_SIGNING_KEY, factories from commerce.tests import factories
from commerce.tests.mocks import mock_get_orders from commerce.tests.mocks import mock_get_orders
from course_modes.models import CourseMode from course_modes.models import CourseMode
from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories
...@@ -506,7 +506,6 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -506,7 +506,6 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
}) })
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase, ProgramsApiConfigMixin): class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase, ProgramsApiConfigMixin):
""" Tests for the account settings view. """ """ Tests for the account settings view. """
......
...@@ -36,7 +36,7 @@ from course_modes.tests.factories import CourseModeFactory ...@@ -36,7 +36,7 @@ from course_modes.tests.factories import CourseModeFactory
from courseware.url_helpers import get_redirect_url from courseware.url_helpers import get_redirect_url
from common.test.utils import XssTestMixin from common.test.utils import XssTestMixin
from commerce.models import CommerceConfiguration from commerce.models import CommerceConfiguration
from commerce.tests import TEST_PAYMENT_DATA, TEST_API_URL, TEST_API_SIGNING_KEY, TEST_PUBLIC_URL_ROOT from commerce.tests import TEST_PAYMENT_DATA, TEST_API_URL, TEST_PUBLIC_URL_ROOT
from openedx.core.djangoapps.embargo.test_utils import restrict_course from openedx.core.djangoapps.embargo.test_utils import restrict_course
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
...@@ -140,7 +140,6 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin): ...@@ -140,7 +140,6 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin):
@httpretty.activate @httpretty.activate
@override_settings( @override_settings(
ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_URL=TEST_API_URL,
ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY,
ECOMMERCE_PUBLIC_URL_ROOT=TEST_PUBLIC_URL_ROOT ECOMMERCE_PUBLIC_URL_ROOT=TEST_PUBLIC_URL_ROOT
) )
def test_start_flow_with_ecommerce(self): def test_start_flow_with_ecommerce(self):
...@@ -1053,7 +1052,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin): ...@@ -1053,7 +1052,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin):
self.assertEqual(response_dict['course_name'], mode_display_name) self.assertEqual(response_dict['course_name'], mode_display_name)
@httpretty.activate @httpretty.activate
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY) @override_settings(ECOMMERCE_API_URL=TEST_API_URL)
@ddt.data("verify_student_start_flow", "verify_student_begin_flow") @ddt.data("verify_student_start_flow", "verify_student_begin_flow")
def test_processors_api(self, payment_flow): def test_processors_api(self, payment_flow):
""" """
...@@ -1223,7 +1222,7 @@ class TestCreateOrderShoppingCart(CheckoutTestMixin, ModuleStoreTestCase): ...@@ -1223,7 +1222,7 @@ class TestCreateOrderShoppingCart(CheckoutTestMixin, ModuleStoreTestCase):
@attr(shard=2) @attr(shard=2)
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY) @override_settings(ECOMMERCE_API_URL=TEST_API_URL)
@patch( @patch(
'lms.djangoapps.verify_student.views.checkout_with_ecommerce_service', 'lms.djangoapps.verify_student.views.checkout_with_ecommerce_service',
return_value=TEST_PAYMENT_DATA, return_value=TEST_PAYMENT_DATA,
...@@ -1248,7 +1247,7 @@ class TestCheckoutWithEcommerceService(ModuleStoreTestCase): ...@@ -1248,7 +1247,7 @@ class TestCheckoutWithEcommerceService(ModuleStoreTestCase):
""" """
@httpretty.activate @httpretty.activate
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY) @override_settings(ECOMMERCE_API_URL=TEST_API_URL)
def test_create_basket(self): def test_create_basket(self):
""" """
Check that when working with a product being processed by the Check that when working with a product being processed by the
......
...@@ -776,7 +776,6 @@ ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_B ...@@ -776,7 +776,6 @@ ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_B
##### ECOMMERCE API CONFIGURATION SETTINGS ##### ##### ECOMMERCE API CONFIGURATION SETTINGS #####
ECOMMERCE_PUBLIC_URL_ROOT = ENV_TOKENS.get('ECOMMERCE_PUBLIC_URL_ROOT', ECOMMERCE_PUBLIC_URL_ROOT) ECOMMERCE_PUBLIC_URL_ROOT = ENV_TOKENS.get('ECOMMERCE_PUBLIC_URL_ROOT', ECOMMERCE_PUBLIC_URL_ROOT)
ECOMMERCE_API_URL = ENV_TOKENS.get('ECOMMERCE_API_URL', ECOMMERCE_API_URL) ECOMMERCE_API_URL = ENV_TOKENS.get('ECOMMERCE_API_URL', ECOMMERCE_API_URL)
ECOMMERCE_API_SIGNING_KEY = AUTH_TOKENS.get('ECOMMERCE_API_SIGNING_KEY', ECOMMERCE_API_SIGNING_KEY)
ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TIMEOUT) ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TIMEOUT)
COURSE_CATALOG_API_URL = ENV_TOKENS.get('COURSE_CATALOG_API_URL', COURSE_CATALOG_API_URL) COURSE_CATALOG_API_URL = ENV_TOKENS.get('COURSE_CATALOG_API_URL', COURSE_CATALOG_API_URL)
......
...@@ -218,7 +218,6 @@ BADGING_BACKEND = 'lms.djangoapps.badges.backends.tests.dummy_backend.DummyBacke ...@@ -218,7 +218,6 @@ BADGING_BACKEND = 'lms.djangoapps.badges.backends.tests.dummy_backend.DummyBacke
# Configure the LMS to use our stub eCommerce implementation # Configure the LMS to use our stub eCommerce implementation
ECOMMERCE_API_URL = 'http://localhost:8043/api/v2/' ECOMMERCE_API_URL = 'http://localhost:8043/api/v2/'
ECOMMERCE_API_SIGNING_KEY = 'ecommerce-key'
LMS_ROOT_URL = "http://localhost:8000" LMS_ROOT_URL = "http://localhost:8000"
DOC_LINK_BASE_URL = 'http://edx.readthedocs.io/projects/edx-guide-for-students' DOC_LINK_BASE_URL = 'http://edx.readthedocs.io/projects/edx-guide-for-students'
......
...@@ -2831,7 +2831,6 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ...@@ -2831,7 +2831,6 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
# E-Commerce API Configuration # E-Commerce API Configuration
ECOMMERCE_PUBLIC_URL_ROOT = None ECOMMERCE_PUBLIC_URL_ROOT = None
ECOMMERCE_API_URL = None ECOMMERCE_API_URL = None
ECOMMERCE_API_SIGNING_KEY = None
ECOMMERCE_API_TIMEOUT = 5 ECOMMERCE_API_TIMEOUT = 5
ECOMMERCE_SERVICE_WORKER_USERNAME = 'ecommerce_worker' ECOMMERCE_SERVICE_WORKER_USERNAME = 'ecommerce_worker'
ENTERPRISE_SERVICE_WORKER_USERNAME = 'enterprise_worker' ENTERPRISE_SERVICE_WORKER_USERNAME = 'enterprise_worker'
......
...@@ -588,3 +588,5 @@ COMPREHENSIVE_THEME_DIRS = [REPO_ROOT / "themes", REPO_ROOT / "common/test"] ...@@ -588,3 +588,5 @@ COMPREHENSIVE_THEME_DIRS = [REPO_ROOT / "themes", REPO_ROOT / "common/test"]
COMPREHENSIVE_THEME_LOCALE_PATHS = [REPO_ROOT / "themes/conf/locale", ] COMPREHENSIVE_THEME_LOCALE_PATHS = [REPO_ROOT / "themes/conf/locale", ]
LMS_ROOT_URL = "http://localhost:8000" LMS_ROOT_URL = "http://localhost:8000"
ECOMMERCE_API_URL = 'https://ecommerce.example.com/api/v2/'
...@@ -4,13 +4,14 @@ from edx_rest_api_client.client import EdxRestApiClient ...@@ -4,13 +4,14 @@ from edx_rest_api_client.client import EdxRestApiClient
from eventtracking import tracker from eventtracking import tracker
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.lib.token_utils import JwtBuilder
ECOMMERCE_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" ECOMMERCE_DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
def create_tracking_context(user): def create_tracking_context(user):
""" Assembles attributes from user and request objects to be sent along """ Assembles attributes from user and request objects to be sent along
in ecommerce api calls for tracking purposes. """ in E-Commerce API calls for tracking purposes. """
context_tracker = tracker.get_tracker().resolve_context() context_tracker = tracker.get_tracker().resolve_context()
return { return {
...@@ -22,27 +23,19 @@ def create_tracking_context(user): ...@@ -22,27 +23,19 @@ def create_tracking_context(user):
def is_commerce_service_configured(): def is_commerce_service_configured():
""" """
Return a Boolean indicating whether or not configuration is present to use Return a Boolean indicating whether or not configuration is present to use the external commerce service.
the external commerce service.
""" """
ecommerce_api_url = configuration_helpers.get_value("ECOMMERCE_API_URL", settings.ECOMMERCE_API_URL) ecommerce_api_url = configuration_helpers.get_value('ECOMMERCE_API_URL', settings.ECOMMERCE_API_URL)
ecommerce_api_signing_key = configuration_helpers.get_value( return bool(ecommerce_api_url)
"ECOMMERCE_API_SIGNING_KEY", settings.ECOMMERCE_API_SIGNING_KEY,
)
return bool(ecommerce_api_url and ecommerce_api_signing_key)
def ecommerce_api_client(user, session=None): def ecommerce_api_client(user, session=None, token_expiration=None):
""" Returns an E-Commerce API client setup with authentication for the specified user. """ """ Returns an E-Commerce API client setup with authentication for the specified user. """
jwt_auth = configuration_helpers.get_value("JWT_AUTH", settings.JWT_AUTH) claims = {'tracking_context': create_tracking_context(user)}
jwt = JwtBuilder(user).build_token(['email', 'profile'], expires_in=token_expiration, additional_claims=claims)
return EdxRestApiClient( return EdxRestApiClient(
configuration_helpers.get_value("ECOMMERCE_API_URL", settings.ECOMMERCE_API_URL), configuration_helpers.get_value('ECOMMERCE_API_URL', settings.ECOMMERCE_API_URL),
configuration_helpers.get_value("ECOMMERCE_API_SIGNING_KEY", settings.ECOMMERCE_API_SIGNING_KEY), jwt=jwt,
user.username,
user.profile.name if hasattr(user, 'profile') else None,
user.email,
tracking_context=create_tracking_context(user),
issuer=jwt_auth['JWT_ISSUER'],
expires_in=jwt_auth['JWT_EXPIRATION'],
session=session session=session
) )
...@@ -11,7 +11,7 @@ from django.test.utils import override_settings ...@@ -11,7 +11,7 @@ from django.test.utils import override_settings
from django.db import connection from django.db import connection
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
import httpretty import httpretty
from lms.djangoapps.commerce.tests import TEST_API_SIGNING_KEY, TEST_API_URL from lms.djangoapps.commerce.tests import TEST_API_URL
import mock import mock
import pytz import pytz
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
...@@ -575,7 +575,6 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -575,7 +575,6 @@ class CreditRequirementApiTests(CreditApiTestBase):
@httpretty.activate @httpretty.activate
@override_settings( @override_settings(
ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_URL=TEST_API_URL,
ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY,
ECOMMERCE_SERVICE_WORKER_USERNAME=TEST_ECOMMERCE_WORKER ECOMMERCE_SERVICE_WORKER_USERNAME=TEST_ECOMMERCE_WORKER
) )
def test_satisfy_all_requirements(self): def test_satisfy_all_requirements(self):
...@@ -622,7 +621,7 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -622,7 +621,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
self.assertFalse(api.is_user_eligible_for_credit(user.username, self.course_key)) self.assertFalse(api.is_user_eligible_for_credit(user.username, self.course_key))
# Satisfy the other requirement # Satisfy the other requirement
with self.assertNumQueries(21): with self.assertNumQueries(25):
api.set_credit_requirement_status( api.set_credit_requirement_status(
user, user,
self.course_key, self.course_key,
...@@ -676,7 +675,7 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -676,7 +675,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
# Delete the eligibility entries and satisfy the user's eligibility # Delete the eligibility entries and satisfy the user's eligibility
# requirement again to trigger eligibility notification # requirement again to trigger eligibility notification
CreditEligibility.objects.all().delete() CreditEligibility.objects.all().delete()
with self.assertNumQueries(16): with self.assertNumQueries(17):
api.set_credit_requirement_status( api.set_credit_requirement_status(
user, user,
self.course_key, self.course_key,
...@@ -1167,7 +1166,6 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): ...@@ -1167,7 +1166,6 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
@skip_unless_lms @skip_unless_lms
@override_settings( @override_settings(
ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_URL=TEST_API_URL,
ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY,
ECOMMERCE_SERVICE_WORKER_USERNAME=TEST_ECOMMERCE_WORKER ECOMMERCE_SERVICE_WORKER_USERNAME=TEST_ECOMMERCE_WORKER
) )
@ddt.ddt @ddt.ddt
......
...@@ -19,7 +19,6 @@ from student.tests.factories import UserFactory ...@@ -19,7 +19,6 @@ from student.tests.factories import UserFactory
UTILITY_MODULE = 'openedx.core.lib.edx_api_utils' UTILITY_MODULE = 'openedx.core.lib.edx_api_utils'
TEST_API_URL = 'http://www-internal.example.com/api' TEST_API_URL = 'http://www-internal.example.com/api'
TEST_API_SIGNING_KEY = 'edx'
@skip_unless_lms @skip_unless_lms
...@@ -200,8 +199,7 @@ class TestGetEdxApiData(ProgramsApiConfigMixin, CacheIsolationTestCase): ...@@ -200,8 +199,7 @@ class TestGetEdxApiData(ProgramsApiConfigMixin, CacheIsolationTestCase):
self.assertTrue(mock_exception.called) self.assertTrue(mock_exception.called)
self.assertEqual(actual, []) self.assertEqual(actual, [])
@override_settings(JWT_AUTH={'JWT_ISSUER': 'http://example.com/oauth', 'JWT_EXPIRATION': 30}, @override_settings(ECOMMERCE_API_URL=TEST_API_URL)
ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY, ECOMMERCE_API_URL=TEST_API_URL)
def test_client_passed(self): def test_client_passed(self):
""" Verify that when API client is passed edx_rest_api_client is not """ Verify that when API client is passed edx_rest_api_client is not
used. used.
......
...@@ -33,17 +33,22 @@ class JwtBuilder(object): ...@@ -33,17 +33,22 @@ class JwtBuilder(object):
self.secret = secret self.secret = secret
self.jwt_auth = configuration_helpers.get_value('JWT_AUTH', settings.JWT_AUTH) self.jwt_auth = configuration_helpers.get_value('JWT_AUTH', settings.JWT_AUTH)
def build_token(self, scopes, expires_in, aud=None): def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None):
"""Returns a JWT access token. """Returns a JWT access token.
Arguments: Arguments:
scopes (list): Scopes controlling which optional claims are included in the token. scopes (list): Scopes controlling which optional claims are included in the token.
expires_in (int): Time to token expiry, specified in seconds.
Keyword Arguments: Keyword Arguments:
expires_in (int): Time to token expiry, specified in seconds.
aud (string): Overrides configured JWT audience claim. aud (string): Overrides configured JWT audience claim.
additional_claims (dict): Additional claims to include in the token.
Returns:
str: Encoded JWT
""" """
now = int(time()) now = int(time())
expires_in = expires_in or self.jwt_auth['JWT_EXPIRATION']
payload = { payload = {
'aud': aud if aud else self.jwt_auth['JWT_AUDIENCE'], 'aud': aud if aud else self.jwt_auth['JWT_AUDIENCE'],
'exp': now + expires_in, 'exp': now + expires_in,
...@@ -54,6 +59,9 @@ class JwtBuilder(object): ...@@ -54,6 +59,9 @@ class JwtBuilder(object):
'sub': anonymous_id_for_user(self.user, None), 'sub': anonymous_id_for_user(self.user, None),
} }
if additional_claims:
payload.update(additional_claims)
for scope in scopes: for scope in scopes:
handler = self.claim_handlers.get(scope) handler = self.claim_handlers.get(scope)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment