From 609e6ca277ccd1570a330bf56a53f018868d15dd Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Fri, 10 Jul 2015 17:36:27 -0400 Subject: [PATCH] Mark basket creation and order retrieval endpoints as v0 These endpoints are currently for internal use only, but should be versioned nonetheless; Drupal will begin using the basket creation endpoint soon. No functionality has been changed. XCOM-494. --- lms/djangoapps/commerce/api/urls.py | 1 + lms/djangoapps/commerce/api/v0/__init__.py | 0 .../commerce/api/v0/tests/__init__.py | 0 .../commerce/api/v0/tests/test_views.py | 333 ++++++++++++++++++ lms/djangoapps/commerce/api/v0/urls.py | 16 + lms/djangoapps/commerce/api/v0/views.py | 159 +++++++++ lms/djangoapps/commerce/api/v1/urls.py | 1 + lms/djangoapps/commerce/tests/test_views.py | 324 +---------------- lms/djangoapps/commerce/urls.py | 9 +- lms/djangoapps/commerce/views.py | 152 -------- lms/static/js/commerce/views/receipt_view.js | 2 +- .../spec/student_account/enrollment_spec.js | 2 +- lms/static/js/student_account/enrollment.js | 2 +- 13 files changed, 517 insertions(+), 484 deletions(-) create mode 100644 lms/djangoapps/commerce/api/v0/__init__.py create mode 100644 lms/djangoapps/commerce/api/v0/tests/__init__.py create mode 100644 lms/djangoapps/commerce/api/v0/tests/test_views.py create mode 100644 lms/djangoapps/commerce/api/v0/urls.py create mode 100644 lms/djangoapps/commerce/api/v0/views.py diff --git a/lms/djangoapps/commerce/api/urls.py b/lms/djangoapps/commerce/api/urls.py index 6e216cb041..1129750f1c 100644 --- a/lms/djangoapps/commerce/api/urls.py +++ b/lms/djangoapps/commerce/api/urls.py @@ -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')), ) diff --git a/lms/djangoapps/commerce/api/v0/__init__.py b/lms/djangoapps/commerce/api/v0/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/commerce/api/v0/tests/__init__.py b/lms/djangoapps/commerce/api/v0/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/commerce/api/v0/tests/test_views.py b/lms/djangoapps/commerce/api/v0/tests/test_views.py new file mode 100644 index 0000000000..0c91875546 --- /dev/null +++ b/lms/djangoapps/commerce/api/v0/tests/test_views.py @@ -0,0 +1,333 @@ +""" Commerce API v0 view tests. """ +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 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): + """ + 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_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) + + +@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) diff --git a/lms/djangoapps/commerce/api/v0/urls.py b/lms/djangoapps/commerce/api/v0/urls.py new file mode 100644 index 0000000000..e802738db9 --- /dev/null +++ b/lms/djangoapps/commerce/api/v0/urls.py @@ -0,0 +1,16 @@ +""" 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[\w]+)'), views.BasketOrderView.as_view(), name='retrieve_order'), +) + +urlpatterns = patterns( + '', + url(r'^baskets/', include(BASKET_URLS, namespace='baskets')), +) diff --git a/lms/djangoapps/commerce/api/v0/views.py b/lms/djangoapps/commerce/api/v0/views.py new file mode 100644 index 0000000000..220263f8c7 --- /dev/null +++ b/lms/djangoapps/commerce/api/v0/views.py @@ -0,0 +1,159 @@ +""" 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.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 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 + ) + + +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) diff --git a/lms/djangoapps/commerce/api/v1/urls.py b/lms/djangoapps/commerce/api/v1/urls.py index 3fa0ea40d8..b62dc4947b 100644 --- a/lms/djangoapps/commerce/api/v1/urls.py +++ b/lms/djangoapps/commerce/api/v1/urls.py @@ -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'), diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py index 0d91ed44bb..f04b530deb 100644 --- a/lms/djangoapps/commerce/tests/test_views.py +++ b/lms/djangoapps/commerce/tests/test_views.py @@ -1,29 +1,14 @@ """ 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): @@ -38,313 +23,6 @@ class UserMixin(object): self.client.login(username=self.user.username, password='test') -@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): diff --git a/lms/djangoapps/commerce/urls.py b/lms/djangoapps/commerce/urls.py index e06fcb2053..4b068ef0c7 100644 --- a/lms/djangoapps/commerce/urls.py +++ b/lms/djangoapps/commerce/urls.py @@ -1,16 +1,13 @@ """ Defines the URL routes for this app. """ - from django.conf.urls import patterns, url, include from commerce import views -BASKET_ID_PATTERN = r'(?P[\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'), ) diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py index 2c10f33e9c..f74038b8a2 100644 --- a/lms/djangoapps/commerce/views.py +++ b/lms/djangoapps/commerce/views.py @@ -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. """ @@ -205,18 +68,3 @@ def checkout_receipt(request): 'payment_support_email': payment_support_email, } 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) diff --git a/lms/static/js/commerce/views/receipt_view.js b/lms/static/js/commerce/views/receipt_view.js index 0542c10ad6..0c4067b42c 100644 --- a/lms/static/js/commerce/views/receipt_view.js +++ b/lms/static/js/commerce/views/receipt_view.js @@ -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), diff --git a/lms/static/js/spec/student_account/enrollment_spec.js b/lms/static/js/spec/student_account/enrollment_spec.js index 59f1f8eeb9..b4e23bf124 100644 --- a/lms/static/js/spec/student_account/enrollment_spec.js +++ b/lms/static/js/spec/student_account/enrollment_spec.js @@ -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/'; diff --git a/lms/static/js/student_account/enrollment.js b/lms/static/js/student_account/enrollment.js index 6374e2f3e4..4053d98b6d 100644 --- a/lms/static/js/student_account/enrollment.js +++ b/lms/static/js/student_account/enrollment.js @@ -9,7 +9,7 @@ var edx = edx || {}; edx.student.account.EnrollmentInterface = { urls: { - baskets: '/commerce/baskets/', + baskets: '/api/commerce/v0/baskets/', }, headers: {