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): ...@@ -56,9 +56,9 @@ class Course(models.Model):
super(Course, self).save(force_insert, force_update, using, update_fields) super(Course, self).save(force_insert, force_update, using, update_fields)
self._create_parent_seat() self._create_parent_seat()
def publish_to_lms(self): def publish_to_lms(self, access_token=None):
""" Publish Course and Products to LMS. """ """ Publish Course and Products to LMS. """
return LMSPublisher().publish(self) return LMSPublisher().publish(self, access_token=access_token)
@classmethod @classmethod
def is_mode_verified(cls, mode): def is_mode_verified(cls, mode):
......
...@@ -5,11 +5,15 @@ from django.conf import settings ...@@ -5,11 +5,15 @@ from django.conf import settings
import requests import requests
from ecommerce.courses.utils import mode_for_seat from ecommerce.courses.utils import mode_for_seat
from ecommerce.settings.base import get_lms_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class LMSPublisher(object): class LMSPublisher(object):
timeout = settings.COMMERCE_API_TIMEOUT
def get_seat_expiration(self, seat): def get_seat_expiration(self, seat):
if not seat.expires or 'professional' in getattr(seat.attr, 'certificate_type', ''): if not seat.expires or 'professional' in getattr(seat.attr, 'certificate_type', ''):
return None return None
...@@ -30,14 +34,46 @@ class LMSPublisher(object): ...@@ -30,14 +34,46 @@ class LMSPublisher(object):
'expires': self.get_seat_expiration(seat), '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. """ 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. course (Course): Course to be published.
Keyword Arguments:
access_token (str): Access token used when publishing CreditCourse data to the LMS.
Returns: Returns:
True, if publish operation succeeded; otherwise, False. True, if publish operation succeeded; otherwise, False.
""" """
...@@ -50,6 +86,32 @@ class LMSPublisher(object): ...@@ -50,6 +86,32 @@ class LMSPublisher(object):
name = course.name name = course.name
verification_deadline = self.get_course_verification_deadline(course) verification_deadline = self.get_course_verification_deadline(course)
modes = [self.serialize_seat_for_commerce_api(seat) for seat in course.seat_products] 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 = { data = {
'id': course_id, 'id': course_id,
'name': name, 'name': name,
...@@ -58,7 +120,6 @@ class LMSPublisher(object): ...@@ -58,7 +120,6 @@ class LMSPublisher(object):
} }
url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), course_id) url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), course_id)
timeout = settings.COMMERCE_API_TIMEOUT
headers = { headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...@@ -66,7 +127,7 @@ class LMSPublisher(object): ...@@ -66,7 +127,7 @@ class LMSPublisher(object):
} }
try: 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 status_code = response.status_code
if status_code in (200, 201): if status_code in (200, 201):
logger.info(u'Successfully published commerce data for [%s].', course_id) logger.info(u'Successfully published commerce data for [%s].', course_id)
......
...@@ -13,6 +13,8 @@ from testfixtures import LogCapture ...@@ -13,6 +13,8 @@ from testfixtures import LogCapture
from ecommerce.courses.models import Course from ecommerce.courses.models import Course
from ecommerce.courses.publishers import LMSPublisher from ecommerce.courses.publishers import LMSPublisher
from ecommerce.extensions.catalogue.tests.mixins import CourseCatalogTestMixin from ecommerce.extensions.catalogue.tests.mixins import CourseCatalogTestMixin
from ecommerce.settings.base import get_lms_url
EDX_API_KEY = 'edx' EDX_API_KEY = 'edx'
JSON = 'application/json' JSON = 'application/json'
...@@ -37,6 +39,28 @@ class LMSPublisherTests(CourseCatalogTestMixin, TestCase): ...@@ -37,6 +39,28 @@ class LMSPublisherTests(CourseCatalogTestMixin, TestCase):
httpretty.register_uri(httpretty.PUT, url, status=status, body=json.dumps(body), httpretty.register_uri(httpretty.PUT, url, status=status, body=json.dumps(body),
content_type=JSON) 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) @ddt.data('', None)
def test_commerce_api_url_not_set(self, setting_value): 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 """ """ If the Commerce API is not setup, the method should log an INFO message and return """
...@@ -139,3 +163,80 @@ class LMSPublisherTests(CourseCatalogTestMixin, TestCase): ...@@ -139,3 +163,80 @@ class LMSPublisherTests(CourseCatalogTestMixin, TestCase):
'expires': None 'expires': None
} }
self.assertDictEqual(actual, expected) 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 ...@@ -162,6 +162,11 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
verification_deadline = serializers.DateTimeField(required=False, allow_null=True) verification_deadline = serializers.DateTimeField(required=False, allow_null=True)
products = serializers.ListField() 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): def validate_products(self, products):
"""Validate product data.""" """Validate product data."""
for product in products: for product in products:
...@@ -236,7 +241,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab ...@@ -236,7 +241,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
credit_hours=credit_hours, credit_hours=credit_hours,
) )
published = course.publish_to_lms() published = course.publish_to_lms(access_token=self.access_token)
if published: if published:
return created, None, None return created, None, None
else: else:
......
...@@ -74,6 +74,29 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase): ...@@ -74,6 +74,29 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase):
'value': True '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): ...@@ -514,7 +514,8 @@ class CourseViewSet(NonDestroyableModelViewSet):
'because the switch [publish_course_modes_to_lms] is disabled.' 'because the switch [publish_course_modes_to_lms] is disabled.'
if waffle.switch_is_active('publish_course_modes_to_lms'): 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: if published:
msg = 'Course [{course_id}] was successfully published to LMS.' msg = 'Course [{course_id}] was successfully published to LMS.'
else: else:
...@@ -538,6 +539,11 @@ class AtomicPublicationView(generics.CreateAPIView, generics.UpdateAPIView): ...@@ -538,6 +539,11 @@ class AtomicPublicationView(generics.CreateAPIView, generics.UpdateAPIView):
permission_classes = (IsAuthenticated, IsAdminUser,) permission_classes = (IsAuthenticated, IsAdminUser,)
serializer_class = serializers.AtomicPublicationSerializer 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): def post(self, request, *args, **kwargs):
return self._save_and_publish(request.data) return self._save_and_publish(request.data)
......
...@@ -202,7 +202,7 @@ class Command(BaseCommand): ...@@ -202,7 +202,7 @@ class Command(BaseCommand):
if options.get('commit', False): if options.get('commit', False):
logger.info('Course [%s] was saved to the database.', course.id) logger.info('Course [%s] was saved to the database.', course.id)
if waffle.switch_is_active('publish_course_modes_to_lms'): if waffle.switch_is_active('publish_course_modes_to_lms'):
course.publish_to_lms() course.publish_to_lms(access_token=access_token)
else: else:
logger.info('Data was not published to LMS because the switch ' logger.info('Data was not published to LMS because the switch '
'[publish_course_modes_to_lms] is disabled.') '[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