Commit 7673bde8 by Max Rothman

Merge pull request #8941 from edx/jsa/commerce-api-rc

Jsa/commerce api rc
parents 7c5cc14a 7449f685
......@@ -3,5 +3,6 @@ from django.conf.urls import patterns, url, include
urlpatterns = patterns(
'',
url(r'^v0/', include('commerce.api.v0.urls', namespace='v0')),
url(r'^v1/', include('commerce.api.v1.urls', namespace='v1')),
)
""" Commerce API v0 view tests. """
import json
import itertools
from uuid import uuid4
import ddt
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.utils import override_settings
import mock
from nose.plugins.attrib import attr
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
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.mocks import mock_basket_order, mock_create_basket
from commerce.tests.test_views import UserMixin
from course_modes.models import CourseMode
from ecommerce_api_client import exceptions
from embargo.test_utils import restrict_course
from enrollment.api import get_enrollment
from openedx.core.lib.django_test_client_utils import get_absolute_url
from student.models import CourseEnrollment
from student.tests.factories import CourseModeFactory
from student.tests.tests import EnrollmentEventTestMixin
@attr('shard_1')
@ddt.ddt
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase):
"""
Tests for the commerce orders view.
"""
def _post_to_view(self, course_id=None, marketing_email_opt_in=False):
"""
POST to the view being tested.
Arguments
course_id (str) -- ID of course for which a seat should be ordered.
:return: Response
"""
payload = {
"course_id": unicode(course_id or self.course.id)
}
if marketing_email_opt_in:
payload["email_opt_in"] = True
return self.client.post(self.url, payload)
def assertResponseMessage(self, response, expected_msg):
""" Asserts the detail field in the response's JSON body equals the expected message. """
actual = json.loads(response.content)['detail']
self.assertEqual(actual, expected_msg)
def assertResponsePaymentData(self, response):
""" Asserts correctness of a JSON body containing payment information. """
actual_response = json.loads(response.content)
self.assertEqual(actual_response, TEST_PAYMENT_DATA)
def assertValidEcommerceInternalRequestErrorResponse(self, response):
""" Asserts the response is a valid response sent when the E-Commerce API is unavailable. """
self.assertEqual(response.status_code, 500)
actual = json.loads(response.content)['detail']
self.assertIn('Call to E-Commerce API failed', actual)
def assertUserNotEnrolled(self):
""" Asserts that the user is NOT enrolled in the course, and that an enrollment event was NOT fired. """
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
self.assert_no_events_were_emitted()
def setUp(self):
super(BasketsViewTests, self).setUp()
self.url = reverse('commerce_api:v0:baskets:create')
self._login()
self.course = CourseFactory.create()
# TODO Verify this is the best method to create CourseMode objects.
# TODO Find/create constants for the modes.
for mode in [CourseMode.HONOR, CourseMode.VERIFIED, CourseMode.AUDIT]:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
mode_display_name=mode,
sku=uuid4().hex.decode('ascii')
)
# Ignore events fired from UserFactory creation
self.reset_tracker()
@mock.patch.dict(settings.FEATURES, {'EMBARGO': True})
def test_embargo_restriction(self):
"""
The view should return HTTP 403 status if the course is embargoed.
"""
with restrict_course(self.course.id) as redirect_url:
response = self._post_to_view()
self.assertEqual(403, response.status_code)
body = json.loads(response.content)
self.assertEqual(get_absolute_url(redirect_url), body['user_message_url'])
def test_login_required(self):
"""
The view should return HTTP 403 status if the user is not logged in.
"""
self.client.logout()
self.assertEqual(403, self._post_to_view().status_code)
@ddt.data('delete', 'get', 'put')
def test_post_required(self, method):
"""
Verify that the view only responds to POST operations.
"""
response = getattr(self.client, method)(self.url)
self.assertEqual(405, response.status_code)
def test_invalid_course(self):
"""
If the course does not exist, the view should return HTTP 406.
"""
# TODO Test inactive courses, and those not open for enrollment.
self.assertEqual(406, self._post_to_view('aaa/bbb/ccc').status_code)
def test_invalid_request_data(self):
"""
If invalid data is supplied with the request, the view should return HTTP 406.
"""
self.assertEqual(406, self.client.post(self.url, {}).status_code)
self.assertEqual(406, self.client.post(self.url, {'not_course_id': ''}).status_code)
def test_ecommerce_api_timeout(self):
"""
If the call to the E-Commerce API times out, the view should log an error and return an HTTP 503 status.
"""
with mock_create_basket(exception=exceptions.Timeout):
response = self._post_to_view()
self.assertValidEcommerceInternalRequestErrorResponse(response)
self.assertUserNotEnrolled()
def test_ecommerce_api_error(self):
"""
If the E-Commerce API raises an error, the view should return an HTTP 503 status.
"""
with mock_create_basket(exception=exceptions.SlumberBaseException):
response = self._post_to_view()
self.assertValidEcommerceInternalRequestErrorResponse(response)
self.assertUserNotEnrolled()
def _test_successful_ecommerce_api_call(self, is_completed=True):
"""
Verifies that the view contacts the E-Commerce API with the correct data and headers.
"""
with mock.patch('commerce.api.v0.views.audit_log') as mock_audit_log:
response = self._post_to_view()
# Verify that an audit message was logged
self.assertTrue(mock_audit_log.called)
# Validate the response content
if is_completed:
msg = Messages.ORDER_COMPLETED.format(order_number=TEST_ORDER_NUMBER)
self.assertResponseMessage(response, msg)
else:
self.assertResponsePaymentData(response)
@ddt.data(True, False)
def test_course_with_honor_seat_sku(self, user_is_active):
"""
If the course has a SKU, the view should get authorization from the E-Commerce API before enrolling
the user in the course. If authorization is approved, the user should be redirected to the user dashboard.
"""
# Set user's active flag
self.user.is_active = user_is_active
self.user.save() # pylint: disable=no-member
return_value = {'id': TEST_BASKET_ID, 'payment_data': None, 'order': {'number': TEST_ORDER_NUMBER}}
with mock_create_basket(response=return_value):
self._test_successful_ecommerce_api_call()
@ddt.data(True, False)
def test_course_with_paid_seat_sku(self, user_is_active):
"""
If the course has a SKU, the view should return data that the client
will use to redirect the user to an external payment processor.
"""
# Set user's active flag
self.user.is_active = user_is_active
self.user.save() # pylint: disable=no-member
return_value = {'id': TEST_BASKET_ID, 'payment_data': TEST_PAYMENT_DATA, 'order': None}
with mock_create_basket(response=return_value):
self._test_successful_ecommerce_api_call(False)
def _test_course_without_sku(self):
"""
Validates the view bypasses the E-Commerce API when the course has no CourseModes with SKUs.
"""
# Place an order
with mock_create_basket(expect_called=False):
response = self._post_to_view()
# Validate the response content
self.assertEqual(response.status_code, 200)
msg = Messages.NO_SKU_ENROLLED.format(enrollment_mode='honor', course_id=self.course.id,
username=self.user.username)
self.assertResponseMessage(response, msg)
def test_course_without_sku(self):
"""
If the course does NOT have a SKU, the user should be enrolled in the course (under the honor mode) and
redirected to the user dashboard.
"""
# Remove SKU from all course modes
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
course_mode.sku = None
course_mode.save()
self._test_course_without_sku()
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
def test_ecommerce_service_not_configured(self):
"""
If the E-Commerce Service is not configured, the view should enroll the user.
"""
with mock_create_basket(expect_called=False):
response = self._post_to_view()
# Validate the response
self.assertEqual(response.status_code, 200)
msg = Messages.NO_ECOM_API.format(username=self.user.username, course_id=self.course.id)
self.assertResponseMessage(response, msg)
# Ensure that the user is not enrolled and that no calls were made to the E-Commerce API
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
def assertProfessionalModeBypassed(self):
""" Verifies that the view returns HTTP 406 when a course with no honor mode is encountered. """
CourseMode.objects.filter(course_id=self.course.id).delete()
mode = CourseMode.NO_ID_PROFESSIONAL_MODE
CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode,
sku=uuid4().hex.decode('ascii'))
with mock_create_basket(expect_called=False):
response = self._post_to_view()
# The view should return an error status code
self.assertEqual(response.status_code, 406)
msg = Messages.NO_HONOR_MODE.format(course_id=self.course.id)
self.assertResponseMessage(response, msg)
def test_course_with_professional_mode_only(self):
""" Verifies that the view behaves appropriately when the course only has a professional mode. """
self.assertProfessionalModeBypassed()
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
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
the E-Commerce Service is not configured.
"""
self.assertProfessionalModeBypassed()
def test_empty_sku(self):
""" If the CourseMode has an empty string for a SKU, the API should not be used. """
# Set SKU to empty string for all modes.
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
course_mode.sku = ''
course_mode.save()
self._test_course_without_sku()
def test_existing_active_enrollment(self):
""" The view should respond with HTTP 409 if the user has an existing active enrollment for the course. """
# Enroll user in the course
CourseEnrollment.enroll(self.user, self.course.id)
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
response = self._post_to_view()
self.assertEqual(response.status_code, 409)
msg = Messages.ENROLLMENT_EXISTS.format(username=self.user.username, course_id=self.course.id)
self.assertResponseMessage(response, msg)
def test_existing_inactive_enrollment(self):
"""
If the user has an inactive enrollment for the course, the view should behave as if the
user has no enrollment.
"""
# Create an inactive enrollment
CourseEnrollment.enroll(self.user, self.course.id)
CourseEnrollment.unenroll(self.user, self.course.id, True)
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
self.assertIsNotNone(get_enrollment(self.user.username, unicode(self.course.id)))
with mock_create_basket():
self._test_successful_ecommerce_api_call(False)
@mock.patch('commerce.api.v0.views.update_email_opt_in')
@ddt.data(*itertools.product((False, True), (False, True), (False, True)))
@ddt.unpack
def test_marketing_email_opt_in(self, is_opt_in, has_sku, is_exception, mock_update):
"""
Ensures the email opt-in flag is handled, if present, and that problems handling the
flag don't cause the rest of the enrollment transaction to fail.
"""
if not has_sku:
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
course_mode.sku = None
course_mode.save()
if is_exception:
mock_update.side_effect = Exception("boink")
return_value = {'id': TEST_BASKET_ID, 'payment_data': None, 'order': {'number': TEST_ORDER_NUMBER}}
with mock_create_basket(response=return_value, expect_called=has_sku):
response = self._post_to_view(marketing_email_opt_in=is_opt_in)
self.assertEqual(mock_update.called, is_opt_in)
self.assertEqual(response.status_code, 200)
@attr('shard_1')
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
class BasketOrderViewTests(UserMixin, TestCase):
""" Tests for the basket order view. """
view_name = 'commerce_api:v0:baskets:retrieve_order'
MOCK_ORDER = {'number': 1}
path = reverse(view_name, kwargs={'basket_id': 1})
def setUp(self):
super(BasketOrderViewTests, self).setUp()
self._login()
def test_order_found(self):
""" If the order is located, the view should pass the data from the API. """
with mock_basket_order(basket_id=1, response=self.MOCK_ORDER):
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
self.assertEqual(actual, self.MOCK_ORDER)
def test_order_not_found(self):
""" If the order is not found, the view should return a 404. """
with mock_basket_order(basket_id=1, exception=exceptions.HttpNotFoundError):
response = self.client.get(self.path)
self.assertEqual(response.status_code, 404)
def test_login_required(self):
""" The view should return 403 if the user is not logged in. """
self.client.logout()
response = self.client.get(self.path)
self.assertEqual(response.status_code, 403)
""" API v0 URLs. """
from django.conf.urls import patterns, url, include
from commerce.api.v0 import views
BASKET_URLS = patterns(
'',
url(r'^$', views.BasketsView.as_view(), name='create'),
url(r'^{}/order/$'.format(r'(?P<basket_id>[\w]+)'), views.BasketOrderView.as_view(), name='retrieve_order'),
)
urlpatterns = patterns(
'',
url(r'^baskets/', include(BASKET_URLS, namespace='baskets')),
)
""" API v0 views. """
import logging
from ecommerce_api_client import exceptions
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT
from rest_framework.views import APIView
from commerce import ecommerce_api_client
from commerce.constants import Messages
from commerce.exceptions import InvalidResponseError
from commerce.http import DetailResponse, InternalRequestErrorResponse
from commerce.utils import audit_log
from course_modes.models import CourseMode
from courseware import courses
from embargo import api as embargo_api
from enrollment.api import add_enrollment
from enrollment.views import EnrollmentCrossDomainSessionAuth
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from student.models import CourseEnrollment
from util.json_request import JsonResponse
log = logging.getLogger(__name__)
class BasketsView(APIView):
""" Creates a basket with a course seat and enrolls users. """
# LMS utilizes User.user_is_active to indicate email verification, not whether an account is active. Sigh!
authentication_classes = (EnrollmentCrossDomainSessionAuth, OAuth2AuthenticationAllowInactiveUser)
permission_classes = (IsAuthenticated,)
def _is_data_valid(self, request):
"""
Validates the data posted to the view.
Arguments
request -- HTTP request
Returns
Tuple (data_is_valid, course_key, error_msg)
"""
course_id = request.DATA.get('course_id')
if not course_id:
return False, None, u'Field course_id is missing.'
try:
course_key = CourseKey.from_string(course_id)
courses.get_course(course_key)
except (InvalidKeyError, ValueError)as ex:
log.exception(u'Unable to locate course matching %s.', course_id)
return False, None, ex.message
return True, course_key, None
def _enroll(self, course_key, user):
""" Enroll the user in the course. """
add_enrollment(user.username, unicode(course_key))
def _handle_marketing_opt_in(self, request, course_key, user):
"""
Handle the marketing email opt-in flag, if it was set.
Errors here aren't expected, but should not break the outer enrollment transaction.
"""
email_opt_in = request.DATA.get('email_opt_in', None)
if email_opt_in is not None:
try:
update_email_opt_in(user, course_key.org, email_opt_in)
except Exception: # pylint: disable=broad-except
# log the error, return silently
log.exception(
'Failed to handle marketing opt-in flag: user="%s", course="%s"', user.username, course_key
)
def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""
Attempt to create the basket and enroll the user.
"""
user = request.user
valid, course_key, error = self._is_data_valid(request)
if not valid:
return DetailResponse(error, status=HTTP_406_NOT_ACCEPTABLE)
embargo_response = embargo_api.get_embargo_response(request, course_key, user)
if embargo_response:
return embargo_response
# Don't do anything if an enrollment already exists
course_id = unicode(course_key)
enrollment = CourseEnrollment.get_enrollment(user, course_key)
if enrollment and enrollment.is_active:
msg = Messages.ENROLLMENT_EXISTS.format(course_id=course_id, username=user.username)
return DetailResponse(msg, status=HTTP_409_CONFLICT)
# If there is no honor course mode, this most likely a Prof-Ed course. Return an error so that the JS
# redirects to track selection.
honor_mode = CourseMode.mode_for_course(course_key, CourseMode.HONOR)
if not honor_mode:
msg = Messages.NO_HONOR_MODE.format(course_id=course_id)
return DetailResponse(msg, status=HTTP_406_NOT_ACCEPTABLE)
elif not honor_mode.sku:
# If there are no course modes with SKUs, enroll the user without contacting the external API.
msg = Messages.NO_SKU_ENROLLED.format(enrollment_mode=CourseMode.HONOR, course_id=course_id,
username=user.username)
log.debug(msg)
self._enroll(course_key, user)
self._handle_marketing_opt_in(request, course_key, user)
return DetailResponse(msg)
# Setup the API
try:
api = ecommerce_api_client(user)
except ValueError:
self._enroll(course_key, user)
msg = Messages.NO_ECOM_API.format(username=user.username, course_id=unicode(course_key))
log.debug(msg)
return DetailResponse(msg)
response = None
# Make the API call
try:
response_data = api.baskets.post({
'products': [{'sku': honor_mode.sku}],
'checkout': True,
})
payment_data = response_data["payment_data"]
if payment_data:
# Pass data to the client to begin the payment flow.
response = JsonResponse(payment_data)
elif response_data['order']:
# The order was completed immediately because there is no charge.
msg = Messages.ORDER_COMPLETED.format(order_number=response_data['order']['number'])
log.debug(msg)
response = DetailResponse(msg)
else:
msg = u'Unexpected response from basket endpoint.'
log.error(
msg + u' Could not enroll user %(username)s in course %(course_id)s.',
{'username': user.id, 'course_id': course_id},
)
raise InvalidResponseError(msg)
except (exceptions.SlumberBaseException, exceptions.Timeout) as ex:
log.exception(ex.message)
return InternalRequestErrorResponse(ex.message)
finally:
audit_log(
'checkout_requested',
course_id=course_id,
mode=honor_mode.slug,
processor_name=None,
user_id=user.id
)
self._handle_marketing_opt_in(request, course_key, user)
return response
class BasketOrderView(APIView):
""" Retrieve the order associated with a basket. """
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
def get(self, request, *_args, **kwargs):
""" HTTP handler. """
try:
order = ecommerce_api_client(request.user).baskets(kwargs['basket_id']).order.get()
return JsonResponse(order)
except exceptions.HttpNotFoundError:
return JsonResponse(status=404)
......@@ -4,6 +4,7 @@ from django.conf.urls import patterns, url, include
from commerce.api.v1 import views
COURSE_URLS = patterns(
'',
url(r'^$', views.CourseListView.as_view(), name='list'),
......
""" Tests for commerce views. """
import json
from uuid import uuid4
from nose.plugins.attrib import attr
import ddt
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.utils import override_settings
import mock
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ecommerce_api_client import exceptions
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.mocks import mock_basket_order, mock_create_basket
from course_modes.models import CourseMode
from embargo.test_utils import restrict_course
from openedx.core.lib.django_test_client_utils import get_absolute_url
from enrollment.api import get_enrollment
from student.models import CourseEnrollment
from student.tests.factories import UserFactory, CourseModeFactory
from student.tests.tests import EnrollmentEventTestMixin
from student.tests.factories import UserFactory
class UserMixin(object):
......@@ -40,313 +25,6 @@ class UserMixin(object):
@attr('shard_1')
@ddt.ddt
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase):
"""
Tests for the commerce orders view.
"""
def _post_to_view(self, course_id=None):
"""
POST to the view being tested.
Arguments
course_id (str) -- ID of course for which a seat should be ordered.
:return: Response
"""
course_id = unicode(course_id or self.course.id)
return self.client.post(self.url, {'course_id': course_id})
def assertResponseMessage(self, response, expected_msg):
""" Asserts the detail field in the response's JSON body equals the expected message. """
actual = json.loads(response.content)['detail']
self.assertEqual(actual, expected_msg)
def assertResponsePaymentData(self, response):
""" Asserts correctness of a JSON body containing payment information. """
actual_response = json.loads(response.content)
self.assertEqual(actual_response, TEST_PAYMENT_DATA)
def assertValidEcommerceInternalRequestErrorResponse(self, response):
""" Asserts the response is a valid response sent when the E-Commerce API is unavailable. """
self.assertEqual(response.status_code, 500)
actual = json.loads(response.content)['detail']
self.assertIn('Call to E-Commerce API failed', actual)
def assertUserNotEnrolled(self):
""" Asserts that the user is NOT enrolled in the course, and that an enrollment event was NOT fired. """
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
self.assert_no_events_were_emitted()
def setUp(self):
super(BasketsViewTests, self).setUp()
self.url = reverse('commerce:baskets')
self._login()
self.course = CourseFactory.create()
# TODO Verify this is the best method to create CourseMode objects.
# TODO Find/create constants for the modes.
for mode in [CourseMode.HONOR, CourseMode.VERIFIED, CourseMode.AUDIT]:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
mode_display_name=mode,
sku=uuid4().hex.decode('ascii')
)
# Ignore events fired from UserFactory creation
self.reset_tracker()
@mock.patch.dict(settings.FEATURES, {'EMBARGO': True})
def test_embargo_restriction(self):
"""
The view should return HTTP 403 status if the course is embargoed.
"""
with restrict_course(self.course.id) as redirect_url:
response = self._post_to_view()
self.assertEqual(403, response.status_code)
body = json.loads(response.content)
self.assertEqual(get_absolute_url(redirect_url), body['user_message_url'])
def test_login_required(self):
"""
The view should return HTTP 403 status if the user is not logged in.
"""
self.client.logout()
self.assertEqual(403, self._post_to_view().status_code)
@ddt.data('delete', 'get', 'put')
def test_post_required(self, method):
"""
Verify that the view only responds to POST operations.
"""
response = getattr(self.client, method)(self.url)
self.assertEqual(405, response.status_code)
def test_invalid_course(self):
"""
If the course does not exist, the view should return HTTP 406.
"""
# TODO Test inactive courses, and those not open for enrollment.
self.assertEqual(406, self._post_to_view('aaa/bbb/ccc').status_code)
def test_invalid_request_data(self):
"""
If invalid data is supplied with the request, the view should return HTTP 406.
"""
self.assertEqual(406, self.client.post(self.url, {}).status_code)
self.assertEqual(406, self.client.post(self.url, {'not_course_id': ''}).status_code)
def test_ecommerce_api_timeout(self):
"""
If the call to the E-Commerce API times out, the view should log an error and return an HTTP 503 status.
"""
with mock_create_basket(exception=exceptions.Timeout):
response = self._post_to_view()
self.assertValidEcommerceInternalRequestErrorResponse(response)
self.assertUserNotEnrolled()
def test_ecommerce_api_error(self):
"""
If the E-Commerce API raises an error, the view should return an HTTP 503 status.
"""
with mock_create_basket(exception=exceptions.SlumberBaseException):
response = self._post_to_view()
self.assertValidEcommerceInternalRequestErrorResponse(response)
self.assertUserNotEnrolled()
def _test_successful_ecommerce_api_call(self, is_completed=True):
"""
Verifies that the view contacts the E-Commerce API with the correct data and headers.
"""
with mock.patch('commerce.views.audit_log') as mock_audit_log:
response = self._post_to_view()
# Verify that an audit message was logged
self.assertTrue(mock_audit_log.called)
# Validate the response content
if is_completed:
msg = Messages.ORDER_COMPLETED.format(order_number=TEST_ORDER_NUMBER)
self.assertResponseMessage(response, msg)
else:
self.assertResponsePaymentData(response)
@ddt.data(True, False)
def test_course_with_honor_seat_sku(self, user_is_active):
"""
If the course has a SKU, the view should get authorization from the E-Commerce API before enrolling
the user in the course. If authorization is approved, the user should be redirected to the user dashboard.
"""
# Set user's active flag
self.user.is_active = user_is_active
self.user.save() # pylint: disable=no-member
return_value = {'id': TEST_BASKET_ID, 'payment_data': None, 'order': {'number': TEST_ORDER_NUMBER}}
with mock_create_basket(response=return_value):
self._test_successful_ecommerce_api_call()
@ddt.data(True, False)
def test_course_with_paid_seat_sku(self, user_is_active):
"""
If the course has a SKU, the view should return data that the client
will use to redirect the user to an external payment processor.
"""
# Set user's active flag
self.user.is_active = user_is_active
self.user.save() # pylint: disable=no-member
return_value = {'id': TEST_BASKET_ID, 'payment_data': TEST_PAYMENT_DATA, 'order': None}
with mock_create_basket(response=return_value):
self._test_successful_ecommerce_api_call(False)
def _test_course_without_sku(self):
"""
Validates the view bypasses the E-Commerce API when the course has no CourseModes with SKUs.
"""
# Place an order
with mock_create_basket(expect_called=False):
response = self._post_to_view()
# Validate the response content
self.assertEqual(response.status_code, 200)
msg = Messages.NO_SKU_ENROLLED.format(enrollment_mode='honor', course_id=self.course.id,
username=self.user.username)
self.assertResponseMessage(response, msg)
def test_course_without_sku(self):
"""
If the course does NOT have a SKU, the user should be enrolled in the course (under the honor mode) and
redirected to the user dashboard.
"""
# Remove SKU from all course modes
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
course_mode.sku = None
course_mode.save()
self._test_course_without_sku()
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
def test_ecommerce_service_not_configured(self):
"""
If the E-Commerce Service is not configured, the view should enroll the user.
"""
with mock_create_basket(expect_called=False):
response = self._post_to_view()
# Validate the response
self.assertEqual(response.status_code, 200)
msg = Messages.NO_ECOM_API.format(username=self.user.username, course_id=self.course.id)
self.assertResponseMessage(response, msg)
# Ensure that the user is not enrolled and that no calls were made to the E-Commerce API
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
def assertProfessionalModeBypassed(self):
""" Verifies that the view returns HTTP 406 when a course with no honor mode is encountered. """
CourseMode.objects.filter(course_id=self.course.id).delete()
mode = CourseMode.NO_ID_PROFESSIONAL_MODE
CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode,
sku=uuid4().hex.decode('ascii'))
with mock_create_basket(expect_called=False):
response = self._post_to_view()
# The view should return an error status code
self.assertEqual(response.status_code, 406)
msg = Messages.NO_HONOR_MODE.format(course_id=self.course.id)
self.assertResponseMessage(response, msg)
def test_course_with_professional_mode_only(self):
""" Verifies that the view behaves appropriately when the course only has a professional mode. """
self.assertProfessionalModeBypassed()
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
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
the E-Commerce Service is not configured.
"""
self.assertProfessionalModeBypassed()
def test_empty_sku(self):
""" If the CourseMode has an empty string for a SKU, the API should not be used. """
# Set SKU to empty string for all modes.
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
course_mode.sku = ''
course_mode.save()
self._test_course_without_sku()
def test_existing_active_enrollment(self):
""" The view should respond with HTTP 409 if the user has an existing active enrollment for the course. """
# Enroll user in the course
CourseEnrollment.enroll(self.user, self.course.id)
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
response = self._post_to_view()
self.assertEqual(response.status_code, 409)
msg = Messages.ENROLLMENT_EXISTS.format(username=self.user.username, course_id=self.course.id)
self.assertResponseMessage(response, msg)
def test_existing_inactive_enrollment(self):
"""
If the user has an inactive enrollment for the course, the view should behave as if the
user has no enrollment.
"""
# Create an inactive enrollment
CourseEnrollment.enroll(self.user, self.course.id)
CourseEnrollment.unenroll(self.user, self.course.id, True)
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
self.assertIsNotNone(get_enrollment(self.user.username, unicode(self.course.id)))
with mock_create_basket():
self._test_successful_ecommerce_api_call(False)
@attr('shard_1')
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
class BasketOrderViewTests(UserMixin, TestCase):
""" Tests for the basket order view. """
view_name = 'commerce:basket_order'
MOCK_ORDER = {'number': 1}
path = reverse(view_name, kwargs={'basket_id': 1})
def setUp(self):
super(BasketOrderViewTests, self).setUp()
self._login()
def test_order_found(self):
""" If the order is located, the view should pass the data from the API. """
with mock_basket_order(basket_id=1, response=self.MOCK_ORDER):
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
self.assertEqual(actual, self.MOCK_ORDER)
def test_order_not_found(self):
""" If the order is not found, the view should return a 404. """
with mock_basket_order(basket_id=1, exception=exceptions.HttpNotFoundError):
response = self.client.get(self.path)
self.assertEqual(response.status_code, 404)
def test_login_required(self):
""" The view should return 403 if the user is not logged in. """
self.client.logout()
response = self.client.get(self.path)
self.assertEqual(response.status_code, 403)
@attr('shard_1')
@ddt.ddt
class ReceiptViewTests(UserMixin, TestCase):
""" Tests for the receipt view. """
......
"""
Defines the URL routes for this app.
"""
from django.conf.urls import patterns, url, include
from commerce import views
BASKET_ID_PATTERN = r'(?P<basket_id>[\w]+)'
urlpatterns = patterns(
'',
url(r'^baskets/$', views.BasketsView.as_view(), name="baskets"),
url(r'^baskets/{}/order/$'.format(BASKET_ID_PATTERN), views.BasketOrderView.as_view(), name="basket_order"),
url(r'^checkout/cancel/$', views.checkout_cancel, name="checkout_cancel"),
url(r'^checkout/receipt/$', views.checkout_receipt, name="checkout_receipt"),
url(r'^checkout/cancel/$', views.checkout_cancel, name='checkout_cancel'),
url(r'^checkout/receipt/$', views.checkout_receipt, name='checkout_receipt'),
)
......@@ -4,29 +4,9 @@ import logging
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from ecommerce_api_client import exceptions
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT
from rest_framework.views import APIView
from commerce import ecommerce_api_client
from commerce.constants import Messages
from commerce.exceptions import InvalidResponseError
from commerce.http import DetailResponse, InternalRequestErrorResponse
from commerce.utils import audit_log
from course_modes.models import CourseMode
from courseware import courses
from edxmako.shortcuts import render_to_response
from enrollment.api import add_enrollment
from enrollment.views import EnrollmentCrossDomainSessionAuth
from embargo import api as embargo_api
from microsite_configuration import microsite
from student.models import CourseEnrollment
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from util.json_request import JsonResponse
from verify_student.models import SoftwareSecurePhotoVerification
from shoppingcart.processors.CyberSource2 import is_user_payment_error
from django.utils.translation import ugettext as _
......@@ -35,123 +15,6 @@ from django.utils.translation import ugettext as _
log = logging.getLogger(__name__)
class BasketsView(APIView):
""" Creates a basket with a course seat and enrolls users. """
# LMS utilizes User.user_is_active to indicate email verification, not whether an account is active. Sigh!
authentication_classes = (EnrollmentCrossDomainSessionAuth, OAuth2AuthenticationAllowInactiveUser)
permission_classes = (IsAuthenticated,)
def _is_data_valid(self, request):
"""
Validates the data posted to the view.
Arguments
request -- HTTP request
Returns
Tuple (data_is_valid, course_key, error_msg)
"""
course_id = request.DATA.get('course_id')
if not course_id:
return False, None, u'Field course_id is missing.'
try:
course_key = CourseKey.from_string(course_id)
courses.get_course(course_key)
except (InvalidKeyError, ValueError)as ex:
log.exception(u'Unable to locate course matching %s.', course_id)
return False, None, ex.message
return True, course_key, None
def _enroll(self, course_key, user):
""" Enroll the user in the course. """
add_enrollment(user.username, unicode(course_key))
def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""
Attempt to create the basket and enroll the user.
"""
user = request.user
valid, course_key, error = self._is_data_valid(request)
if not valid:
return DetailResponse(error, status=HTTP_406_NOT_ACCEPTABLE)
embargo_response = embargo_api.get_embargo_response(request, course_key, user)
if embargo_response:
return embargo_response
# Don't do anything if an enrollment already exists
course_id = unicode(course_key)
enrollment = CourseEnrollment.get_enrollment(user, course_key)
if enrollment and enrollment.is_active:
msg = Messages.ENROLLMENT_EXISTS.format(course_id=course_id, username=user.username)
return DetailResponse(msg, status=HTTP_409_CONFLICT)
# If there is no honor course mode, this most likely a Prof-Ed course. Return an error so that the JS
# redirects to track selection.
honor_mode = CourseMode.mode_for_course(course_key, CourseMode.HONOR)
if not honor_mode:
msg = Messages.NO_HONOR_MODE.format(course_id=course_id)
return DetailResponse(msg, status=HTTP_406_NOT_ACCEPTABLE)
elif not honor_mode.sku:
# If there are no course modes with SKUs, enroll the user without contacting the external API.
msg = Messages.NO_SKU_ENROLLED.format(enrollment_mode=CourseMode.HONOR, course_id=course_id,
username=user.username)
log.debug(msg)
self._enroll(course_key, user)
return DetailResponse(msg)
# Setup the API
try:
api = ecommerce_api_client(user)
except ValueError:
self._enroll(course_key, user)
msg = Messages.NO_ECOM_API.format(username=user.username, course_id=unicode(course_key))
log.debug(msg)
return DetailResponse(msg)
# Make the API call
try:
response_data = api.baskets.post({
'products': [{'sku': honor_mode.sku}],
'checkout': True,
})
payment_data = response_data["payment_data"]
if payment_data:
# Pass data to the client to begin the payment flow.
return JsonResponse(payment_data)
elif response_data['order']:
# The order was completed immediately because there is no charge.
msg = Messages.ORDER_COMPLETED.format(order_number=response_data['order']['number'])
log.debug(msg)
return DetailResponse(msg)
else:
msg = u'Unexpected response from basket endpoint.'
log.error(
msg + u' Could not enroll user %(username)s in course %(course_id)s.',
{'username': user.id, 'course_id': course_id},
)
raise InvalidResponseError(msg)
except (exceptions.SlumberBaseException, exceptions.Timeout) as ex:
log.exception(ex.message)
return InternalRequestErrorResponse(ex.message)
finally:
audit_log(
'checkout_requested',
course_id=course_id,
mode=honor_mode.slug,
processor_name=None,
user_id=user.id
)
@csrf_exempt
def checkout_cancel(_request):
""" Checkout/payment cancellation view. """
......@@ -206,18 +69,3 @@ def checkout_receipt(request):
'nav_hidden': True,
}
return render_to_response('commerce/checkout_receipt.html', context)
class BasketOrderView(APIView):
""" Retrieve the order associated with a basket. """
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
def get(self, request, *_args, **kwargs):
""" HTTP handler. """
try:
order = ecommerce_api_client(request.user).baskets(kwargs['basket_id']).order.get()
return JsonResponse(order)
except exceptions.HttpNotFoundError:
return JsonResponse(status=404)
......@@ -83,7 +83,7 @@ var edx = edx || {};
* @return {object} JQuery Promise.
*/
getReceiptData: function (basketId) {
var urlFormat = this.useEcommerceApi ? '/commerce/baskets/%s/order/' : '/shoppingcart/receipt/%s/';
var urlFormat = this.useEcommerceApi ? '/api/commerce/v0/baskets/%s/order/' : '/shoppingcart/receipt/%s/';
return $.ajax({
url: _.sprintf(urlFormat, basketId),
......
......@@ -5,7 +5,7 @@ define(['common/js/spec_helpers/ajax_helpers', 'js/student_account/enrollment'],
describe( 'edx.student.account.EnrollmentInterface', function() {
var COURSE_KEY = 'edX/DemoX/Fall',
ENROLL_URL = '/commerce/baskets/',
ENROLL_URL = '/api/commerce/v0/baskets/',
FORWARD_URL = '/course_modes/choose/edX/DemoX/Fall/',
EMBARGO_MSG_URL = '/embargo/blocked-message/enrollment/default/';
......
......@@ -9,7 +9,7 @@ var edx = edx || {};
edx.student.account.EnrollmentInterface = {
urls: {
baskets: '/commerce/baskets/',
baskets: '/api/commerce/v0/baskets/',
},
headers: {
......
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