Merge pull request #7378 from edx/clintonb/pay-with-oscar
Creating orders with E-Commerce API
This commit is contained in:
86
lms/djangoapps/commerce/api.py
Normal file
86
lms/djangoapps/commerce/api.py
Normal file
@@ -0,0 +1,86 @@
|
||||
""" E-Commerce API client """
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
import jwt
|
||||
import requests
|
||||
from requests import Timeout
|
||||
from rest_framework.status import HTTP_200_OK
|
||||
|
||||
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EcommerceAPI(object):
|
||||
""" E-Commerce API client. """
|
||||
|
||||
def __init__(self, url=None, key=None, timeout=None):
|
||||
self.url = url or settings.ECOMMERCE_API_URL
|
||||
self.key = key or settings.ECOMMERCE_API_SIGNING_KEY
|
||||
self.timeout = timeout or getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5)
|
||||
|
||||
if not (self.url and self.key):
|
||||
raise InvalidConfigurationError('Values for both url and key must be set.')
|
||||
|
||||
# Remove slashes, so that we can properly format URLs regardless of
|
||||
# whether the input includes a trailing slash.
|
||||
self.url = self.url.strip('/')
|
||||
|
||||
def _get_jwt(self, user):
|
||||
"""
|
||||
Returns a JWT object with the specified user's info.
|
||||
|
||||
Raises AttributeError if settings.ECOMMERCE_API_SIGNING_KEY is not set.
|
||||
"""
|
||||
data = {
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
}
|
||||
return jwt.encode(data, self.key)
|
||||
|
||||
def create_order(self, user, sku):
|
||||
"""
|
||||
Create a new order.
|
||||
|
||||
Arguments
|
||||
user -- User for which the order should be created.
|
||||
sku -- SKU of the course seat being ordered.
|
||||
|
||||
Returns a tuple with the order number, order status, API response data.
|
||||
"""
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'JWT {}'.format(self._get_jwt(user))
|
||||
}
|
||||
|
||||
url = '{}/orders/'.format(self.url)
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=json.dumps({'sku': sku}), headers=headers, timeout=self.timeout)
|
||||
data = response.json()
|
||||
except Timeout:
|
||||
msg = 'E-Commerce API request timed out.'
|
||||
log.error(msg)
|
||||
raise TimeoutError(msg)
|
||||
|
||||
except ValueError:
|
||||
msg = 'E-Commerce API response is not valid JSON.'
|
||||
log.exception(msg)
|
||||
raise InvalidResponseError(msg)
|
||||
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == HTTP_200_OK:
|
||||
return data['number'], data['status'], data
|
||||
else:
|
||||
msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s'
|
||||
msg_kwargs = {
|
||||
'status': status_code,
|
||||
'msg': data.get('user_message'),
|
||||
}
|
||||
log.error(msg, msg_kwargs)
|
||||
raise InvalidResponseError(msg % msg_kwargs)
|
||||
21
lms/djangoapps/commerce/exceptions.py
Normal file
21
lms/djangoapps/commerce/exceptions.py
Normal file
@@ -0,0 +1,21 @@
|
||||
""" E-Commerce-related exceptions. """
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
""" Base class for E-Commerce API errors. """
|
||||
pass
|
||||
|
||||
|
||||
class InvalidConfigurationError(ApiError):
|
||||
""" Exception raised when the API is not properly configured (e.g. settings are not set). """
|
||||
pass
|
||||
|
||||
|
||||
class InvalidResponseError(ApiError):
|
||||
""" Exception raised when an API response is invalid. """
|
||||
pass
|
||||
|
||||
|
||||
class TimeoutError(ApiError):
|
||||
""" Exception raised when an API requests times out. """
|
||||
pass
|
||||
73
lms/djangoapps/commerce/tests/__init__.py
Normal file
73
lms/djangoapps/commerce/tests/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
||||
""" Commerce app tests package. """
|
||||
import json
|
||||
|
||||
import httpretty
|
||||
import jwt
|
||||
import mock
|
||||
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.constants import OrderStatus
|
||||
|
||||
|
||||
class EcommerceApiTestMixin(object):
|
||||
""" Mixin for tests utilizing the E-Commerce API. """
|
||||
|
||||
ECOMMERCE_API_URL = 'http://example.com/api'
|
||||
ECOMMERCE_API_SIGNING_KEY = 'edx'
|
||||
ORDER_NUMBER = '100004'
|
||||
ECOMMERCE_API_SUCCESSFUL_BODY = {
|
||||
'status': OrderStatus.COMPLETE,
|
||||
'number': ORDER_NUMBER,
|
||||
'payment_processor': 'cybersource',
|
||||
'payment_parameters': {'orderNumber': ORDER_NUMBER}
|
||||
}
|
||||
ECOMMERCE_API_SUCCESSFUL_BODY_JSON = json.dumps(ECOMMERCE_API_SUCCESSFUL_BODY) # pylint: disable=invalid-name
|
||||
|
||||
def assertValidJWTAuthHeader(self, request, user, key):
|
||||
""" Verifies that the JWT Authorization header is correct. """
|
||||
expected_jwt = jwt.encode({'username': user.username, 'email': user.email}, key)
|
||||
self.assertEqual(request.headers['Authorization'], 'JWT {}'.format(expected_jwt))
|
||||
|
||||
def assertValidOrderRequest(self, request, user, jwt_signing_key, sku):
|
||||
""" Verifies that an order request to the E-Commerce Service is valid. """
|
||||
self.assertValidJWTAuthHeader(request, user, jwt_signing_key)
|
||||
|
||||
self.assertEqual(request.body, '{{"sku": "{}"}}'.format(sku))
|
||||
self.assertEqual(request.headers['Content-Type'], 'application/json')
|
||||
|
||||
def _mock_ecommerce_api(self, status=200, body=None):
|
||||
"""
|
||||
Mock calls to the E-Commerce API.
|
||||
|
||||
The calling test should be decorated with @httpretty.activate.
|
||||
"""
|
||||
self.assertTrue(httpretty.is_enabled(), 'Test is missing @httpretty.activate decorator.')
|
||||
|
||||
url = self.ECOMMERCE_API_URL + '/orders/'
|
||||
body = body or self.ECOMMERCE_API_SUCCESSFUL_BODY_JSON
|
||||
httpretty.register_uri(httpretty.POST, url, status=status, body=body)
|
||||
|
||||
class mock_create_order(object): # pylint: disable=invalid-name
|
||||
""" Mocks calls to EcommerceAPI.create_order. """
|
||||
|
||||
patch = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
default_kwargs = {
|
||||
'return_value': (
|
||||
EcommerceApiTestMixin.ORDER_NUMBER,
|
||||
OrderStatus.COMPLETE,
|
||||
EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY
|
||||
)
|
||||
}
|
||||
|
||||
default_kwargs.update(kwargs)
|
||||
|
||||
self.patch = mock.patch.object(EcommerceAPI, 'create_order', mock.Mock(**default_kwargs))
|
||||
|
||||
def __enter__(self):
|
||||
self.patch.start()
|
||||
return self.patch.new
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=unused-argument
|
||||
self.patch.stop()
|
||||
88
lms/djangoapps/commerce/tests/test_api.py
Normal file
88
lms/djangoapps/commerce/tests/test_api.py
Normal file
@@ -0,0 +1,88 @@
|
||||
""" Tests the E-Commerce API module. """
|
||||
|
||||
import json
|
||||
|
||||
from ddt import ddt, data
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.testcases import TestCase
|
||||
from django.test.utils import override_settings
|
||||
import httpretty
|
||||
from requests import Timeout
|
||||
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.constants import OrderStatus
|
||||
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
|
||||
from commerce.tests import EcommerceApiTestMixin
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
@ddt
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
|
||||
""" Tests for the E-Commerce API client. """
|
||||
|
||||
SKU = '1234'
|
||||
|
||||
def setUp(self):
|
||||
super(EcommerceAPITests, self).setUp()
|
||||
self.url = reverse('commerce:orders')
|
||||
self.user = UserFactory()
|
||||
self.api = EcommerceAPI()
|
||||
|
||||
def test_constructor_url_strip(self):
|
||||
""" Verifies that the URL is stored with trailing slashes removed. """
|
||||
url = 'http://example.com'
|
||||
api = EcommerceAPI(url, 'edx')
|
||||
self.assertEqual(api.url, url)
|
||||
|
||||
api = EcommerceAPI(url + '/', 'edx')
|
||||
self.assertEqual(api.url, url)
|
||||
|
||||
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
|
||||
def test_no_settings(self):
|
||||
"""
|
||||
If the settings ECOMMERCE_API_URL and ECOMMERCE_API_SIGNING_KEY are invalid, the constructor should
|
||||
raise a ValueError.
|
||||
"""
|
||||
self.assertRaises(InvalidConfigurationError, EcommerceAPI)
|
||||
|
||||
@httpretty.activate
|
||||
def test_create_order(self):
|
||||
""" Verify the method makes a call to the E-Commerce API with the correct headers and data. """
|
||||
self._mock_ecommerce_api()
|
||||
number, status, body = self.api.create_order(self.user, self.SKU)
|
||||
|
||||
# Validate the request sent to the E-Commerce API endpoint.
|
||||
request = httpretty.last_request()
|
||||
self.assertValidOrderRequest(request, self.user, self.ECOMMERCE_API_SIGNING_KEY, self.SKU)
|
||||
|
||||
# Validate the data returned by the method
|
||||
self.assertEqual(number, self.ORDER_NUMBER)
|
||||
self.assertEqual(status, OrderStatus.COMPLETE)
|
||||
self.assertEqual(body, self.ECOMMERCE_API_SUCCESSFUL_BODY)
|
||||
|
||||
@httpretty.activate
|
||||
@data(400, 401, 405, 406, 429, 500, 503)
|
||||
def test_create_order_with_invalid_http_status(self, status):
|
||||
""" If the E-Commerce API returns a non-200 status, the method should raise an InvalidResponseError. """
|
||||
self._mock_ecommerce_api(status=status, body=json.dumps({'user_message': 'FAIL!'}))
|
||||
self.assertRaises(InvalidResponseError, self.api.create_order, self.user, self.SKU)
|
||||
|
||||
@httpretty.activate
|
||||
def test_create_order_with_invalid_json(self):
|
||||
""" If the E-Commerce API returns un-parseable data, the method should raise an InvalidResponseError. """
|
||||
self._mock_ecommerce_api(body='TOTALLY NOT JSON!')
|
||||
self.assertRaises(InvalidResponseError, self.api.create_order, self.user, self.SKU)
|
||||
|
||||
@httpretty.activate
|
||||
def test_create_order_with_timeout(self):
|
||||
""" If the call to the E-Commerce API times out, the method should raise a TimeoutError. """
|
||||
|
||||
def request_callback(_request, _uri, _headers):
|
||||
""" Simulates API timeout """
|
||||
raise Timeout
|
||||
|
||||
self._mock_ecommerce_api(body=request_callback)
|
||||
|
||||
self.assertRaises(TimeoutError, self.api.create_order, self.user, self.SKU)
|
||||
@@ -6,14 +6,12 @@ from uuid import uuid4
|
||||
from ddt import ddt, data
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.utils import override_settings
|
||||
import httpretty
|
||||
from httpretty.core import HTTPrettyRequestEmpty
|
||||
import jwt
|
||||
from requests import Timeout
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from commerce.constants import OrderStatus, Messages
|
||||
from commerce.exceptions import TimeoutError, ApiError
|
||||
from commerce.tests import EcommerceApiTestMixin
|
||||
from course_modes.models import CourseMode
|
||||
from enrollment.api import get_enrollment
|
||||
from student.models import CourseEnrollment
|
||||
@@ -21,15 +19,10 @@ from student.tests.factories import UserFactory, CourseModeFactory
|
||||
from student.tests.tests import EnrollmentEventTestMixin
|
||||
|
||||
|
||||
ECOMMERCE_API_URL = 'http://example.com/api'
|
||||
ECOMMERCE_API_SIGNING_KEY = 'edx'
|
||||
ORDER_NUMBER = "100004"
|
||||
ECOMMERCE_API_SUCCESSFUL_BODY = json.dumps({'status': OrderStatus.COMPLETE, 'number': ORDER_NUMBER})
|
||||
|
||||
|
||||
@ddt
|
||||
@override_settings(ECOMMERCE_API_URL=ECOMMERCE_API_URL, ECOMMERCE_API_SIGNING_KEY=ECOMMERCE_API_SIGNING_KEY)
|
||||
class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the commerce orders view.
|
||||
"""
|
||||
@@ -50,18 +43,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
course_id = unicode(course_id or self.course.id)
|
||||
return self.client.post(self.url, {'course_id': course_id})
|
||||
|
||||
def _mock_ecommerce_api(self, status=200, body=None):
|
||||
"""
|
||||
Mock calls to the E-Commerce API.
|
||||
|
||||
The calling test should be decorated with @httpretty.activate.
|
||||
"""
|
||||
self.assertTrue(httpretty.is_enabled(), 'Test is missing @httpretty.activate decorator.')
|
||||
|
||||
url = ECOMMERCE_API_URL + '/orders/'
|
||||
body = body or ECOMMERCE_API_SUCCESSFUL_BODY
|
||||
httpretty.register_uri(httpretty.POST, url, status=status, body=body)
|
||||
|
||||
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']
|
||||
@@ -72,11 +53,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertResponseMessage(response, 'Call to E-Commerce API failed. Order creation failed.')
|
||||
|
||||
def assertUserEnrolled(self):
|
||||
""" Asserts that the user is enrolled in the course, and that an enrollment event was fired. """
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
self.assert_enrollment_event_was_emitted(self.user, self.course.id)
|
||||
|
||||
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))
|
||||
@@ -92,7 +68,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
|
||||
# TODO Verify this is the best method to create CourseMode objects.
|
||||
# TODO Find/create constants for the modes.
|
||||
for mode in ['honor', 'verified', 'audit']:
|
||||
for mode in [CourseMode.HONOR, CourseMode.VERIFIED, CourseMode.AUDIT]:
|
||||
CourseModeFactory.create(
|
||||
course_id=self.course.id,
|
||||
mode_slug=mode,
|
||||
@@ -129,40 +105,23 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
self.assertEqual(406, self.client.post(self.url, {}).status_code)
|
||||
self.assertEqual(406, self.client.post(self.url, {'not_course_id': ''}).status_code)
|
||||
|
||||
@httpretty.activate
|
||||
@data(400, 401, 405, 406, 429, 500, 503)
|
||||
def test_ecommerce_api_bad_status(self, status):
|
||||
"""
|
||||
If the E-Commerce API returns an HTTP status not equal to 200, the view should log an error and return
|
||||
an HTTP 503 status.
|
||||
"""
|
||||
self._mock_ecommerce_api(status=status, body=json.dumps({'user_message': 'FAIL!'}))
|
||||
response = self._post_to_view()
|
||||
self.assertValidEcommerceApiErrorResponse(response)
|
||||
self.assertUserNotEnrolled()
|
||||
|
||||
@httpretty.activate
|
||||
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.
|
||||
"""
|
||||
# Verify that the view responds appropriately if calls to the E-Commerce API timeout.
|
||||
def request_callback(_request, _uri, _headers):
|
||||
""" Simulates API timeout """
|
||||
raise Timeout
|
||||
with self.mock_create_order(side_effect=TimeoutError):
|
||||
response = self._post_to_view()
|
||||
|
||||
self._mock_ecommerce_api(body=request_callback)
|
||||
response = self._post_to_view()
|
||||
self.assertValidEcommerceApiErrorResponse(response)
|
||||
self.assertUserNotEnrolled()
|
||||
|
||||
@httpretty.activate
|
||||
def test_ecommerce_api_bad_data(self):
|
||||
def test_ecommerce_api_error(self):
|
||||
"""
|
||||
If the E-Commerce API returns data that is not JSON, the view should return an HTTP 503 status.
|
||||
If the E-Commerce API raises an error, the view should return an HTTP 503 status.
|
||||
"""
|
||||
self._mock_ecommerce_api(body='TOTALLY NOT JSON!')
|
||||
response = self._post_to_view()
|
||||
with self.mock_create_order(side_effect=ApiError):
|
||||
response = self._post_to_view()
|
||||
|
||||
self.assertValidEcommerceApiErrorResponse(response)
|
||||
self.assertUserNotEnrolled()
|
||||
|
||||
@@ -170,56 +129,45 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Verifies that the view contacts the E-Commerce API with the correct data and headers.
|
||||
"""
|
||||
self._mock_ecommerce_api(body=ECOMMERCE_API_SUCCESSFUL_BODY)
|
||||
response = self._post_to_view()
|
||||
with self.mock_create_order():
|
||||
response = self._post_to_view()
|
||||
|
||||
# Validate the response content
|
||||
msg = Messages.ORDER_COMPLETED.format(order_number=ORDER_NUMBER)
|
||||
msg = Messages.ORDER_COMPLETED.format(order_number=self.ORDER_NUMBER)
|
||||
self.assertResponseMessage(response, msg)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify the correct information was passed to the E-Commerce API
|
||||
request = httpretty.last_request()
|
||||
sku = CourseMode.objects.filter(course_id=self.course.id, mode_slug='honor', sku__isnull=False)[0].sku
|
||||
self.assertEqual(request.body, '{{"sku": "{}"}}'.format(sku))
|
||||
self.assertEqual(request.headers['Content-Type'], 'application/json')
|
||||
|
||||
# Verify the JWT is correct
|
||||
expected_jwt = jwt.encode({'username': self.user.username, 'email': self.user.email},
|
||||
ECOMMERCE_API_SIGNING_KEY)
|
||||
self.assertEqual(request.headers['Authorization'], 'JWT {}'.format(expected_jwt))
|
||||
|
||||
@data(True, False)
|
||||
@httpretty.activate
|
||||
def test_course_with_honor_seat_sku(self, user_is_active):
|
||||
"""
|
||||
If the course has a SKU for honor mode, the view should get authorization from the E-Commerce API before
|
||||
enrolling the user in the course.
|
||||
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
|
||||
|
||||
self._test_successful_ecommerce_api_call()
|
||||
|
||||
@httpretty.activate
|
||||
def test_order_not_complete(self):
|
||||
self._mock_ecommerce_api(body=json.dumps({'status': OrderStatus.OPEN, 'number': ORDER_NUMBER}))
|
||||
response = self._post_to_view()
|
||||
with self.mock_create_order(return_value=(self.ORDER_NUMBER,
|
||||
OrderStatus.OPEN,
|
||||
self.ECOMMERCE_API_SUCCESSFUL_BODY)):
|
||||
response = self._post_to_view()
|
||||
self.assertEqual(response.status_code, 202)
|
||||
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=ORDER_NUMBER)
|
||||
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=self.ORDER_NUMBER)
|
||||
self.assertResponseMessage(response, msg)
|
||||
|
||||
# TODO Eventually we should NOT be enrolling users directly from this view.
|
||||
self.assertUserEnrolled()
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
|
||||
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
|
||||
self._mock_ecommerce_api()
|
||||
response = self._post_to_view()
|
||||
with self.mock_create_order() as api_mock:
|
||||
response = self._post_to_view()
|
||||
|
||||
# Validate the response content
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -227,11 +175,9 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
username=self.user.username)
|
||||
self.assertResponseMessage(response, msg)
|
||||
|
||||
# The user should be enrolled, and no calls made to the E-Commerce API
|
||||
self.assertUserEnrolled()
|
||||
self.assertIsInstance(httpretty.last_request(), HTTPrettyRequestEmpty)
|
||||
# No calls made to the E-Commerce API
|
||||
self.assertFalse(api_mock.called)
|
||||
|
||||
@httpretty.activate
|
||||
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
|
||||
@@ -244,13 +190,13 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
|
||||
self._test_course_without_sku()
|
||||
|
||||
@httpretty.activate
|
||||
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
|
||||
def test_no_settings(self):
|
||||
def test_ecommerce_service_not_configured(self):
|
||||
"""
|
||||
If no settings exist to define the E-Commerce API URL or signing key, the view should enroll the user.
|
||||
If the E-Commerce Service is not configured, the view should enroll the user.
|
||||
"""
|
||||
response = self._post_to_view()
|
||||
with self.mock_create_order() as api_mock:
|
||||
response = self._post_to_view()
|
||||
|
||||
# Validate the response
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -258,10 +204,40 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
self.assertResponseMessage(response, msg)
|
||||
|
||||
# Ensure that the user is not enrolled and that no calls were made to the E-Commerce API
|
||||
self.assertUserEnrolled()
|
||||
self.assertIsInstance(httpretty.last_request(), HTTPrettyRequestEmpty)
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
self.assertFalse(api_mock.called)
|
||||
|
||||
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 self.mock_create_order() as api_mock:
|
||||
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)
|
||||
|
||||
# No calls should be made to the E-Commerce API.
|
||||
self.assertFalse(api_mock.called)
|
||||
|
||||
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()
|
||||
|
||||
@httpretty.activate
|
||||
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.
|
||||
@@ -271,32 +247,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
|
||||
self._test_course_without_sku()
|
||||
|
||||
def _test_professional_mode_only(self):
|
||||
""" Verifies that the view behaves appropriately when the course only has a professional mode. """
|
||||
CourseMode.objects.filter(course_id=self.course.id).delete()
|
||||
mode = 'no-id-professional'
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode,
|
||||
sku=uuid4().hex.decode('ascii'))
|
||||
self._mock_ecommerce_api()
|
||||
response = self._post_to_view()
|
||||
self.assertEqual(response.status_code, 406)
|
||||
msg = Messages.NO_HONOR_MODE.format(course_id=self.course.id)
|
||||
self.assertResponseMessage(response, msg)
|
||||
|
||||
@httpretty.activate
|
||||
def test_course_with_professional_mode_only(self):
|
||||
""" Verifies that the view behaves appropriately when the course only has a professional mode. """
|
||||
self._test_professional_mode_only()
|
||||
|
||||
@httpretty.activate
|
||||
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
|
||||
def test_no_settings_and_professional_mode_only(self):
|
||||
"""
|
||||
Verifies that the view behaves appropriately when the course only has a professional mode and
|
||||
the E-Commerce API is not configured.
|
||||
"""
|
||||
self._test_professional_mode_only()
|
||||
|
||||
def test_existing_active_enrollment(self):
|
||||
""" The view should respond with HTTP 409 if the user has an existing active enrollment for the course. """
|
||||
|
||||
@@ -309,7 +259,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
msg = Messages.ENROLLMENT_EXISTS.format(username=self.user.username, course_id=self.course.id)
|
||||
self.assertResponseMessage(response, msg)
|
||||
|
||||
@httpretty.activate
|
||||
def test_existing_inactive_enrollment(self):
|
||||
"""
|
||||
If the user has an inactive enrollment for the course, the view should behave as if the
|
||||
@@ -1,18 +1,15 @@
|
||||
""" Commerce views. """
|
||||
import json
|
||||
import logging
|
||||
from simplejson import JSONDecodeError
|
||||
|
||||
from django.conf import settings
|
||||
import jwt
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
import requests
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_202_ACCEPTED, HTTP_200_OK, HTTP_409_CONFLICT
|
||||
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_202_ACCEPTED, HTTP_409_CONFLICT
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.constants import OrderStatus, Messages
|
||||
from commerce.exceptions import ApiError, InvalidConfigurationError
|
||||
from commerce.http import DetailResponse, ApiErrorResponse
|
||||
from course_modes.models import CourseMode
|
||||
from courseware import courses
|
||||
@@ -55,17 +52,6 @@ class OrdersView(APIView):
|
||||
|
||||
return True, course_key, None
|
||||
|
||||
def _get_jwt(self, user, ecommerce_api_signing_key):
|
||||
"""
|
||||
Returns a JWT object with the specified user's info.
|
||||
|
||||
"""
|
||||
data = {
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
}
|
||||
return jwt.encode(data, ecommerce_api_signing_key)
|
||||
|
||||
def _enroll(self, course_key, user):
|
||||
""" Enroll the user in the course. """
|
||||
add_enrollment(user.username, unicode(course_key))
|
||||
@@ -79,23 +65,17 @@ class OrdersView(APIView):
|
||||
if not valid:
|
||||
return DetailResponse(error, status=HTTP_406_NOT_ACCEPTABLE)
|
||||
|
||||
# Ensure that the E-Commerce API is setup properly
|
||||
ecommerce_api_url = getattr(settings, 'ECOMMERCE_API_URL', None)
|
||||
ecommerce_api_signing_key = getattr(settings, 'ECOMMERCE_API_SIGNING_KEY', None)
|
||||
course_id = unicode(course_key)
|
||||
|
||||
# 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)
|
||||
|
||||
# Ensure that the course has an honor mode with SKU
|
||||
honor_mode = CourseMode.mode_for_course(course_key, CourseMode.HONOR)
|
||||
course_id = unicode(course_key)
|
||||
|
||||
# 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)
|
||||
@@ -107,40 +87,18 @@ class OrdersView(APIView):
|
||||
self._enroll(course_key, user)
|
||||
return DetailResponse(msg)
|
||||
|
||||
# If the API is not configured, bypass it.
|
||||
if not (ecommerce_api_url and ecommerce_api_signing_key):
|
||||
# Setup the API and report any errors if settings are not valid.
|
||||
try:
|
||||
api = EcommerceAPI()
|
||||
except InvalidConfigurationError:
|
||||
self._enroll(course_key, user)
|
||||
msg = Messages.NO_ECOM_API.format(username=user.username, course_id=course_id)
|
||||
msg = Messages.NO_ECOM_API.format(username=user.username, course_id=unicode(course_key))
|
||||
log.debug(msg)
|
||||
return DetailResponse(msg)
|
||||
|
||||
# Contact external API
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'JWT {}'.format(self._get_jwt(user, ecommerce_api_signing_key))
|
||||
}
|
||||
|
||||
url = '{}/orders/'.format(ecommerce_api_url.strip('/'))
|
||||
|
||||
# Make the API call
|
||||
try:
|
||||
timeout = getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5)
|
||||
response = requests.post(url, data=json.dumps({'sku': honor_mode.sku}), headers=headers,
|
||||
timeout=timeout)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
log.exception('Call to E-Commerce API failed: %s.', ex.message)
|
||||
return ApiErrorResponse()
|
||||
|
||||
status_code = response.status_code
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except JSONDecodeError:
|
||||
log.error('E-Commerce API response is not valid JSON.')
|
||||
return ApiErrorResponse()
|
||||
|
||||
if status_code == HTTP_200_OK:
|
||||
order_number = data.get('number')
|
||||
order_status = data.get('status')
|
||||
order_number, order_status, _body = api.create_order(user, honor_mode.sku)
|
||||
if order_status == OrderStatus.COMPLETE:
|
||||
msg = Messages.ORDER_COMPLETED.format(order_number=order_number)
|
||||
log.debug(msg)
|
||||
@@ -162,12 +120,6 @@ class OrdersView(APIView):
|
||||
|
||||
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=order_number)
|
||||
return DetailResponse(msg, status=HTTP_202_ACCEPTED)
|
||||
else:
|
||||
msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s'
|
||||
msg_kwargs = {
|
||||
'status': status_code,
|
||||
'msg': data.get('user_message'),
|
||||
}
|
||||
log.error(msg, msg_kwargs)
|
||||
|
||||
except ApiError:
|
||||
# The API will handle logging of the error.
|
||||
return ApiErrorResponse()
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
Tests of verify_student views.
|
||||
"""
|
||||
import json
|
||||
import mock
|
||||
import urllib
|
||||
from datetime import timedelta, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from django.test.utils import override_settings
|
||||
import mock
|
||||
from mock import patch, Mock
|
||||
import pytz
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
import ddt
|
||||
from django.test.client import Client
|
||||
from django.test import TestCase
|
||||
@@ -17,14 +19,16 @@ from django.core.urlresolvers import reverse
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core import mail
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
|
||||
from commerce.exceptions import ApiError
|
||||
from commerce.tests import EcommerceApiTestMixin
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from student.models import CourseEnrollment
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
@@ -839,7 +843,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
|
||||
self.assertEqual(response_dict['course_name'], mode_display_name)
|
||||
|
||||
|
||||
class TestCreateOrder(ModuleStoreTestCase):
|
||||
class TestCreateOrder(EcommerceApiTestMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the create order view.
|
||||
"""
|
||||
@@ -850,20 +854,27 @@ class TestCreateOrder(ModuleStoreTestCase):
|
||||
self.user = UserFactory.create(username="test", password="test")
|
||||
self.course = CourseFactory.create()
|
||||
for mode, min_price in (('audit', 0), ('honor', 0), ('verified', 100)):
|
||||
CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price)
|
||||
# Set SKU to empty string to ensure view knows how to handle such values
|
||||
CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price, sku='')
|
||||
self.client.login(username="test", password="test")
|
||||
|
||||
def _post(self, data):
|
||||
"""
|
||||
POST to the view being tested and return the response.
|
||||
"""
|
||||
url = reverse('verify_student_create_order')
|
||||
return self.client.post(url, data)
|
||||
|
||||
def test_create_order_already_verified(self):
|
||||
# Verify the student so we don't need to submit photos
|
||||
self._verify_student()
|
||||
|
||||
# Create an order
|
||||
url = reverse('verify_student_create_order')
|
||||
params = {
|
||||
'course_id': unicode(self.course.id),
|
||||
'contribution': 100
|
||||
}
|
||||
response = self.client.post(url, params)
|
||||
response = self._post(params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify that the information will be sent to the correct callback URL
|
||||
@@ -884,11 +895,8 @@ class TestCreateOrder(ModuleStoreTestCase):
|
||||
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10)
|
||||
|
||||
# Create an order for a prof ed course
|
||||
url = reverse('verify_student_create_order')
|
||||
params = {
|
||||
'course_id': unicode(course.id)
|
||||
}
|
||||
response = self.client.post(url, params)
|
||||
params = {'course_id': unicode(course.id)}
|
||||
response = self._post(params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify that the course ID and transaction type are included in "merchant-defined data"
|
||||
@@ -903,11 +911,8 @@ class TestCreateOrder(ModuleStoreTestCase):
|
||||
CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10)
|
||||
|
||||
# Create an order for a prof ed course
|
||||
url = reverse('verify_student_create_order')
|
||||
params = {
|
||||
'course_id': unicode(course.id)
|
||||
}
|
||||
response = self.client.post(url, params)
|
||||
params = {'course_id': unicode(course.id)}
|
||||
response = self._post(params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify that the course ID and transaction type are included in "merchant-defined data"
|
||||
@@ -923,11 +928,8 @@ class TestCreateOrder(ModuleStoreTestCase):
|
||||
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10)
|
||||
|
||||
# Create an order for a prof ed course
|
||||
url = reverse('verify_student_create_order')
|
||||
params = {
|
||||
'course_id': unicode(course.id)
|
||||
}
|
||||
response = self.client.post(url, params)
|
||||
params = {'course_id': unicode(course.id)}
|
||||
response = self._post(params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify that the course ID and transaction type are included in "merchant-defined data"
|
||||
@@ -940,12 +942,11 @@ class TestCreateOrder(ModuleStoreTestCase):
|
||||
self._verify_student()
|
||||
|
||||
# Create an order
|
||||
url = reverse('verify_student_create_order')
|
||||
params = {
|
||||
'course_id': unicode(self.course.id),
|
||||
'contribution': '1.23'
|
||||
}
|
||||
self.client.post(url, params)
|
||||
self._post(params)
|
||||
|
||||
# Verify that the client's session contains the new donation amount
|
||||
self.assertNotIn('donation_for_course', self.client.session)
|
||||
@@ -957,6 +958,52 @@ class TestCreateOrder(ModuleStoreTestCase):
|
||||
attempt.submit()
|
||||
attempt.approve()
|
||||
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
def test_create_order_with_ecommerce_api(self):
|
||||
""" Verifies that the view communicates with the E-Commerce API to create orders. """
|
||||
# Keep track of the original number of orders to verify the old code is not being called.
|
||||
order_count = Order.objects.count()
|
||||
|
||||
# Add SKU to CourseModes
|
||||
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
|
||||
course_mode.sku = uuid4().hex.decode('ascii')
|
||||
course_mode.save()
|
||||
|
||||
# Mock the E-Commerce Service response
|
||||
with self.mock_create_order():
|
||||
self._verify_student()
|
||||
params = {'course_id': unicode(self.course.id), 'contribution': 100}
|
||||
response = self._post(params)
|
||||
|
||||
# Verify the response is correct.
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response['Content-Type'], 'application/json')
|
||||
self.assertEqual(json.loads(response.content), self.ECOMMERCE_API_SUCCESSFUL_BODY['payment_parameters'])
|
||||
|
||||
# Verify old code is not called (e.g. no Order object created in LMS)
|
||||
self.assertEqual(order_count, Order.objects.count())
|
||||
|
||||
def _add_course_mode_skus(self):
|
||||
""" Add SKUs to the CourseMode objects for self.course. """
|
||||
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
|
||||
course_mode.sku = uuid4().hex.decode('ascii')
|
||||
course_mode.save()
|
||||
|
||||
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
|
||||
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
|
||||
def test_create_order_with_ecommerce_api_errors(self):
|
||||
"""
|
||||
Verifies that the view communicates with the E-Commerce API to create orders, and handles errors
|
||||
appropriately.
|
||||
"""
|
||||
self._add_course_mode_skus()
|
||||
|
||||
with self.mock_create_order(side_effect=ApiError):
|
||||
self._verify_student()
|
||||
params = {'course_id': unicode(self.course.id), 'contribution': 100}
|
||||
self.assertRaises(ApiError, self._post, params)
|
||||
|
||||
|
||||
class TestCreateOrderView(ModuleStoreTestCase):
|
||||
"""
|
||||
|
||||
@@ -7,11 +7,9 @@ import logging
|
||||
import decimal
|
||||
import datetime
|
||||
from collections import namedtuple
|
||||
|
||||
from pytz import UTC
|
||||
from ipware.ip import get_ip
|
||||
|
||||
from edxmako.shortcuts import render_to_response, render_to_string
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import (
|
||||
@@ -26,11 +24,16 @@ from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.mail import send_mail
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from edxmako.shortcuts import render_to_response, render_to_string
|
||||
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings
|
||||
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
|
||||
from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError
|
||||
|
||||
from commerce.api import EcommerceAPI
|
||||
from commerce.exceptions import ApiError
|
||||
from course_modes.models import CourseMode
|
||||
from student.models import CourseEnrollment
|
||||
from student.views import reverification_info
|
||||
@@ -43,17 +46,13 @@ from verify_student.models import (
|
||||
)
|
||||
from reverification.models import MidcourseReverificationWindow
|
||||
import ssencrypt
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from .exceptions import WindowExpiredException
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from microsite_configuration import microsite
|
||||
|
||||
from embargo import api as embargo_api
|
||||
|
||||
from util.json_request import JsonResponse
|
||||
from util.date_utils import get_default_time_display
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
EVENT_NAME_USER_ENTERED_MIDCOURSE_REVERIFY_VIEW = 'edx.course.enrollment.reverify.started'
|
||||
@@ -612,6 +611,21 @@ class PayAndVerifyView(View):
|
||||
return (has_paid, bool(is_active))
|
||||
|
||||
|
||||
def create_order_with_ecommerce_service(user, course_key, course_mode): # pylint: disable=invalid-name
|
||||
""" Create a new order using the E-Commerce API. """
|
||||
try:
|
||||
api = EcommerceAPI()
|
||||
# Make an API call to create the order and retrieve the results
|
||||
_order_number, _order_status, data = api.create_order(user, course_mode.sku)
|
||||
|
||||
# Pass the payment parameters directly from the API response.
|
||||
return HttpResponse(json.dumps(data['payment_parameters']), content_type='application/json')
|
||||
except ApiError:
|
||||
params = {'username': user.username, 'mode': course_mode.slug, 'course_id': unicode(course_key)}
|
||||
log.error('Failed to create order for %(username)s %(mode)s mode of %(course_id)s', params)
|
||||
raise
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def create_order(request):
|
||||
@@ -676,6 +690,9 @@ def create_order(request):
|
||||
if amount < current_mode.min_price:
|
||||
return HttpResponseBadRequest(_("No selected price or selected price is below minimum."))
|
||||
|
||||
if current_mode.sku:
|
||||
return create_order_with_ecommerce_service(request.user, course_id, current_mode)
|
||||
|
||||
# I know, we should check this is valid. All kinds of stuff missing here
|
||||
cart = Order.get_cart_for_user(request.user)
|
||||
cart.clear()
|
||||
|
||||
Reference in New Issue
Block a user