Commit faae1e32 by Renzo Lucioni

Merge pull request #307 from edx/renzo/publish-credit-course

Support publication of Courses with a credit seat
parents 8fd7e228 e2cb8f00
......@@ -56,9 +56,9 @@ class Course(models.Model):
super(Course, self).save(force_insert, force_update, using, update_fields)
self._create_parent_seat()
def publish_to_lms(self):
def publish_to_lms(self, access_token=None):
""" Publish Course and Products to LMS. """
return LMSPublisher().publish(self)
return LMSPublisher().publish(self, access_token=access_token)
@classmethod
def is_mode_verified(cls, mode):
......
......@@ -5,11 +5,15 @@ from django.conf import settings
import requests
from ecommerce.courses.utils import mode_for_seat
from ecommerce.settings.base import get_lms_url
logger = logging.getLogger(__name__)
class LMSPublisher(object):
timeout = settings.COMMERCE_API_TIMEOUT
def get_seat_expiration(self, seat):
if not seat.expires or 'professional' in getattr(seat.attr, 'certificate_type', ''):
return None
......@@ -30,14 +34,46 @@ class LMSPublisher(object):
'expires': self.get_seat_expiration(seat),
}
def publish(self, course):
def _publish_creditcourse(self, course_id, access_token):
"""Creates or updates a CreditCourse object on the LMS."""
url = get_lms_url('api/credit/v1/courses/')
data = {
'course_key': course_id,
'enabled': True
}
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + access_token
}
kwargs = {
'url': url,
'data': json.dumps(data),
'headers': headers,
'timeout': self.timeout
}
response = requests.post(**kwargs)
if response.status_code == 400:
# The CreditCourse already exists. Try updating it.
kwargs['url'] += course_id.strip('/') + '/'
response = requests.put(**kwargs)
return response
def publish(self, course, access_token=None):
""" Publish course commerce data to LMS.
Uses the Commerce API to publish course modes, prices, and SKUs to LMS.
Uses the Commerce API to publish course modes, prices, and SKUs to LMS. Uses
CreditCourse API endpoints to publish CreditCourse data to LMS when necessary.
Args:
Arguments:
course (Course): Course to be published.
Keyword Arguments:
access_token (str): Access token used when publishing CreditCourse data to the LMS.
Returns:
True, if publish operation succeeded; otherwise, False.
"""
......@@ -50,6 +86,32 @@ class LMSPublisher(object):
name = course.name
verification_deadline = self.get_course_verification_deadline(course)
modes = [self.serialize_seat_for_commerce_api(seat) for seat in course.seat_products]
has_credit = 'credit' in [mode['name'] for mode in modes]
if has_credit:
if access_token is not None:
try:
response = self._publish_creditcourse(course_id, access_token)
if response.status_code in (200, 201):
logger.info(u'Successfully published CreditCourse for [%s] to LMS.', course_id)
else:
logger.error(
u'Failed to publish CreditCourse for [%s] to LMS. Status was [%d]. Body was [%s].',
course_id,
response.status_code,
response.content
)
return False
except: # pylint: disable=bare-except
logger.exception(u'Failed to publish CreditCourse for [%s] to LMS.', course_id)
return False
else:
logger.error(
u'Unable to publish CreditCourse for [%s] to LMS. No access token available.',
course_id
)
return False
data = {
'id': course_id,
'name': name,
......@@ -58,7 +120,6 @@ class LMSPublisher(object):
}
url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), course_id)
timeout = settings.COMMERCE_API_TIMEOUT
headers = {
'Content-Type': 'application/json',
......@@ -66,7 +127,7 @@ class LMSPublisher(object):
}
try:
response = requests.put(url, data=json.dumps(data), headers=headers, timeout=timeout)
response = requests.put(url, data=json.dumps(data), headers=headers, timeout=self.timeout)
status_code = response.status_code
if status_code in (200, 201):
logger.info(u'Successfully published commerce data for [%s].', course_id)
......
......@@ -13,6 +13,8 @@ from testfixtures import LogCapture
from ecommerce.courses.models import Course
from ecommerce.courses.publishers import LMSPublisher
from ecommerce.extensions.catalogue.tests.mixins import CourseCatalogTestMixin
from ecommerce.settings.base import get_lms_url
EDX_API_KEY = 'edx'
JSON = 'application/json'
......@@ -37,6 +39,28 @@ class LMSPublisherTests(CourseCatalogTestMixin, TestCase):
httpretty.register_uri(httpretty.PUT, url, status=status, body=json.dumps(body),
content_type=JSON)
def _mock_credit_api(self, creation_status, update_status):
self.assertTrue(httpretty.is_enabled, 'httpretty must be enabled to mock Credit API calls.')
url = get_lms_url('api/credit/v1/courses/')
httpretty.register_uri(
httpretty.POST,
url,
status=creation_status,
body='{}',
content_type=JSON
)
if update_status is not None:
url += self.course.id.strip('/') + '/'
httpretty.register_uri(
httpretty.PUT,
url,
status=update_status,
body='{}',
content_type=JSON
)
@ddt.data('', None)
def test_commerce_api_url_not_set(self, setting_value):
""" If the Commerce API is not setup, the method should log an INFO message and return """
......@@ -139,3 +163,80 @@ class LMSPublisherTests(CourseCatalogTestMixin, TestCase):
'expires': None
}
self.assertDictEqual(actual, expected)
@httpretty.activate
@ddt.data(
(201, None, 201),
(400, 200, 200)
)
@ddt.unpack
def test_credit_publication_success(self, creation_status, update_status, commerce_status):
"""
Verify that course publication succeeds if the Credit API responds
with 2xx status codes when publishing CreditCourse data to the LMS.
"""
self.course.create_or_update_seat('credit', True, 100, credit_provider='Harvard', credit_hours=1)
self._mock_credit_api(creation_status, update_status)
self._mock_commerce_api(commerce_status)
access_token = 'access_token'
published = self.publisher.publish(self.course, access_token=access_token)
self.assertTrue(published)
# Retrieve the latest request to the Credit API.
if creation_status == 400:
latest_request = httpretty.httpretty.latest_requests[1]
else:
latest_request = httpretty.httpretty.latest_requests[0]
# Verify the headers passed to the Credit API were correct.
expected = {
'Content-Type': JSON,
'Authorization': 'Bearer ' + access_token
}
self.assertDictContainsSubset(expected, latest_request.headers)
# Verify the data passed to the Credit API was correct.
expected = {
'course_key': self.course.id,
'enabled': True
}
actual = json.loads(latest_request.body)
self.assertEqual(expected, actual)
@httpretty.activate
def test_credit_publication_failure(self):
"""
Verify that course publication fails if the Credit API does not respond
with 2xx status codes when publishing CreditCourse data to the LMS.
"""
self.course.create_or_update_seat('credit', True, 100, credit_provider='Harvard', credit_hours=1)
self._mock_credit_api(400, 418)
published = self.publisher.publish(self.course, access_token='access_token')
self.assertFalse(published)
def test_credit_publication_no_access_token(self):
"""
Verify that course publication fails if no access token is provided
when publishing CreditCourse data to the LMS.
"""
self.course.create_or_update_seat('credit', True, 100, credit_provider='Harvard', credit_hours=1)
published = self.publisher.publish(self.course, access_token=None)
self.assertFalse(published)
def test_credit_publication_exception(self):
"""
Verify that course publication fails if an exception is raised
while publishing CreditCourse data to the LMS.
"""
self.course.create_or_update_seat('credit', True, 100, credit_provider='Harvard', credit_hours=1)
with mock.patch.object(LMSPublisher, '_publish_creditcourse') as mock_publish_creditcourse:
mock_publish_creditcourse.side_effect = Exception
published = self.publisher.publish(self.course, access_token='access_token')
self.assertFalse(published)
......@@ -162,6 +162,11 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
verification_deadline = serializers.DateTimeField(required=False, allow_null=True)
products = serializers.ListField()
def __init__(self, *args, **kwargs):
super(AtomicPublicationSerializer, self).__init__(*args, **kwargs)
self.access_token = kwargs['context'].pop('access_token')
def validate_products(self, products):
"""Validate product data."""
for product in products:
......@@ -236,7 +241,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
credit_hours=credit_hours,
)
published = course.publish_to_lms()
published = course.publish_to_lms(access_token=self.access_token)
if published:
return created, None, None
else:
......
......@@ -74,6 +74,29 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase):
'value': True
}
]
},
{
'product_class': 'Seat',
'expires': EXPIRES_STRING,
'price': 100.00,
'attribute_values': [
{
'name': 'certificate_type',
'value': 'credit'
},
{
'name': 'id_verification_required',
'value': True
},
{
'name': 'credit_provider',
'value': 'Harvard'
},
{
'name': 'credit_hours',
'value': 1
}
]
}
]
}
......
......@@ -514,7 +514,8 @@ class CourseViewSet(NonDestroyableModelViewSet):
'because the switch [publish_course_modes_to_lms] is disabled.'
if waffle.switch_is_active('publish_course_modes_to_lms'):
published = course.publish_to_lms()
access_token = getattr(request.user, 'access_token', None)
published = course.publish_to_lms(access_token=access_token)
if published:
msg = 'Course [{course_id}] was successfully published to LMS.'
else:
......@@ -538,6 +539,11 @@ class AtomicPublicationView(generics.CreateAPIView, generics.UpdateAPIView):
permission_classes = (IsAuthenticated, IsAdminUser,)
serializer_class = serializers.AtomicPublicationSerializer
def get_serializer_context(self):
context = super(AtomicPublicationView, self).get_serializer_context()
context['access_token'] = self.request.user.access_token
return context
def post(self, request, *args, **kwargs):
return self._save_and_publish(request.data)
......
......@@ -202,7 +202,7 @@ class Command(BaseCommand):
if options.get('commit', False):
logger.info('Course [%s] was saved to the database.', course.id)
if waffle.switch_is_active('publish_course_modes_to_lms'):
course.publish_to_lms()
course.publish_to_lms(access_token=access_token)
else:
logger.info('Data was not published to LMS because the switch '
'[publish_course_modes_to_lms] is disabled.')
......
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