Skip to content
Snippets Groups Projects
Commit 45de93a2 authored by Clinton Blackburn's avatar Clinton Blackburn Committed by Clinton Blackburn
Browse files

Added course endpoints for Commerce API

XCOM-314
parent e145d7f8
No related branches found
No related tags found
No related merge requests found
Showing with 375 additions and 16 deletions
""" API URLs. """
from django.conf.urls import patterns, url, include
urlpatterns = patterns(
'',
url(r'^v1/', include('commerce.api.v1.urls', namespace='v1')),
)
""" API v1 models. """
from itertools import groupby
import logging
from django.db import transaction
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from course_modes.models import CourseMode
log = logging.getLogger(__name__)
class Course(object):
""" Pseudo-course model used to group CourseMode objects. """
id = None # pylint: disable=invalid-name
modes = None
_deleted_modes = None
def __init__(self, id, modes): # pylint: disable=invalid-name,redefined-builtin
self.id = CourseKey.from_string(unicode(id)) # pylint: disable=invalid-name
self.modes = list(modes)
self._deleted_modes = []
@transaction.commit_on_success
def save(self, *args, **kwargs): # pylint: disable=unused-argument
""" Save the CourseMode objects to the database. """
for mode in self.modes:
mode.course_id = self.id
mode.mode_display_name = mode.mode_slug
mode.save()
deleted_mode_ids = [mode.id for mode in self._deleted_modes]
CourseMode.objects.filter(id__in=deleted_mode_ids).delete()
self._deleted_modes = []
def update(self, attrs):
""" Update the model with external data (usually passed via API call). """
existing_modes = {mode.mode_slug: mode for mode in self.modes}
merged_modes = set()
merged_mode_keys = set()
for posted_mode in attrs.get('modes', []):
merged_mode = existing_modes.get(posted_mode.mode_slug, CourseMode())
merged_mode.course_id = self.id
merged_mode.mode_slug = posted_mode.mode_slug
merged_mode.mode_display_name = posted_mode.mode_slug
merged_mode.min_price = posted_mode.min_price
merged_mode.currency = posted_mode.currency
merged_mode.sku = posted_mode.sku
merged_modes.add(merged_mode)
merged_mode_keys.add(merged_mode.mode_slug)
deleted_modes = set(existing_modes.keys()) - merged_mode_keys
self._deleted_modes = [existing_modes[mode] for mode in deleted_modes]
self.modes = list(merged_modes)
@classmethod
def get(cls, course_id):
""" Retrieve a single course. """
try:
course_id = CourseKey.from_string(unicode(course_id))
except InvalidKeyError:
log.debug('[%s] is not a valid course key.', course_id)
raise ValueError
course_modes = CourseMode.objects.filter(course_id=course_id)
if course_modes:
return cls(unicode(course_id), list(course_modes))
return None
@classmethod
def iterator(cls):
""" Generator that yields all courses. """
course_modes = CourseMode.objects.order_by('course_id')
for course_id, modes in groupby(course_modes, lambda o: o.course_id):
yield cls(course_id, list(modes))
""" Custom API permissions. """
from rest_framework.permissions import BasePermission, DjangoModelPermissions
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
class ApiKeyOrModelPermission(BasePermission):
""" Access granted for requests with API key in header,
or made by user with appropriate Django model permissions. """
def has_permission(self, request, view):
return ApiKeyHeaderPermission().has_permission(request, view) or DjangoModelPermissions().has_permission(
request, view)
""" API v1 serializers. """
from rest_framework import serializers
from commerce.api.v1.models import Course
from course_modes.models import CourseMode
class CourseModeSerializer(serializers.ModelSerializer):
""" CourseMode serializer. """
name = serializers.CharField(source='mode_slug')
price = serializers.IntegerField(source='min_price')
def get_identity(self, data):
try:
return data.get('name', None)
except AttributeError:
return None
class Meta(object): # pylint: disable=missing-docstring
model = CourseMode
fields = ('name', 'currency', 'price', 'sku')
class CourseSerializer(serializers.Serializer):
""" Course serializer. """
id = serializers.CharField() # pylint: disable=invalid-name
modes = CourseModeSerializer(many=True, allow_add_remove=True)
def restore_object(self, attrs, instance=None):
if instance is None:
return Course(attrs['id'], attrs['modes'])
instance.update(attrs)
return instance
""" Commerce API v1 tests. """
""" Commerce API v1 view tests. """
import json
import ddt
from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.models import CourseMode
from student.tests.factories import UserFactory
PASSWORD = 'test'
JSON_CONTENT_TYPE = 'application/json'
class CourseApiViewTestMixin(object):
""" Mixin for CourseApi views.
Automatically creates a course and CourseMode.
"""
def setUp(self):
super(CourseApiViewTestMixin, self).setUp()
self.course = CourseFactory.create()
self.course_mode = CourseMode.objects.create(course_id=self.course.id, mode_slug=u'verified', min_price=100,
currency=u'USD', sku=u'ABC123')
@staticmethod
def _serialize_course_mode(course_mode):
""" Serialize a CourseMode to a dict. """
return {
u'name': course_mode.mode_slug,
u'currency': course_mode.currency,
u'price': course_mode.min_price,
u'sku': course_mode.sku
}
class CourseListViewTests(CourseApiViewTestMixin, ModuleStoreTestCase):
""" Tests for CourseListView. """
path = reverse('commerce:api:v1:courses:list')
def test_authentication_required(self):
""" Verify only authenticated users can access the view. """
self.client.logout()
response = self.client.get(self.path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 401)
def test_list(self):
""" Verify the view lists the available courses and modes. """
user = UserFactory.create()
self.client.login(username=user.username, password=PASSWORD)
response = self.client.get(self.path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
expected = [
{
u'id': unicode(self.course.id),
u'modes': [self._serialize_course_mode(self.course_mode)]
}
]
self.assertListEqual(actual, expected)
@ddt.ddt
class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase):
""" Tests for CourseRetrieveUpdateView. """
def setUp(self):
super(CourseRetrieveUpdateViewTests, self).setUp()
self.path = reverse('commerce:api:v1:courses:retrieve_update', args=[unicode(self.course.id)])
self.user = UserFactory.create()
self.client.login(username=self.user.username, password=PASSWORD)
@ddt.data('get', 'post', 'put')
def test_authentication_required(self, method):
""" Verify only authenticated users can access the view. """
self.client.logout()
response = getattr(self.client, method)(self.path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 401)
@ddt.data('post', 'put')
def test_authorization_required(self, method):
""" Verify create/edit operations require appropriate permissions. """
response = getattr(self.client, method)(self.path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 403)
def test_retrieve(self):
""" Verify the view displays info for a given course. """
response = self.client.get(self.path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
expected = {
u'id': unicode(self.course.id),
u'modes': [self._serialize_course_mode(self.course_mode)]
}
self.assertEqual(actual, expected)
def test_retrieve_invalid_course(self):
""" The view should return HTTP 404 when retrieving data for a course that does not exist. """
path = reverse('commerce:api:v1:courses:retrieve_update', args=['a/b/c'])
response = self.client.get(path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 404)
def test_update(self):
""" Verify the view supports updating a course. """
permission = Permission.objects.get(name='Can change course mode')
self.user.user_permissions.add(permission)
expected_course_mode = CourseMode(mode_slug=u'verified', min_price=200, currency=u'USD', sku=u'ABC123')
expected = {
u'id': unicode(self.course.id),
u'modes': [self._serialize_course_mode(expected_course_mode)]
}
response = self.client.put(self.path, json.dumps(expected), content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
self.assertEqual(actual, expected)
def test_update_overwrite(self):
""" Verify that data submitted via PUT overwrites/deletes modes that are
not included in the body of the request. """
permission = Permission.objects.get(name='Can change course mode')
self.user.user_permissions.add(permission)
course_id = unicode(self.course.id)
expected = {
u'id': course_id,
u'modes': [self._serialize_course_mode(
CourseMode(mode_slug=u'credit', min_price=500, currency=u'USD', sku=u'ABC123')), ]
}
path = reverse('commerce:api:v1:courses:retrieve_update', args=[course_id])
response = self.client.put(path, json.dumps(expected), content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
self.assertEqual(actual, expected)
# The existing CourseMode should have been removed.
self.assertFalse(CourseMode.objects.filter(id=self.course_mode.id).exists())
def assert_can_create_course(self, **request_kwargs):
""" Verify a course can be created by the view. """
course = CourseFactory.create()
course_id = unicode(course.id)
expected = {
u'id': course_id,
u'modes': [
self._serialize_course_mode(
CourseMode(mode_slug=u'verified', min_price=150, currency=u'USD', sku=u'ABC123')),
self._serialize_course_mode(
CourseMode(mode_slug=u'honor', min_price=0, currency=u'USD', sku=u'DEADBEEF')),
]
}
path = reverse('commerce:api:v1:courses:retrieve_update', args=[course_id])
response = self.client.put(path, json.dumps(expected), content_type=JSON_CONTENT_TYPE, **request_kwargs)
self.assertEqual(response.status_code, 201)
actual = json.loads(response.content)
self.assertEqual(actual, expected)
def test_create_with_permissions(self):
""" Verify the view supports creating a course as a user with the appropriate permissions. """
permissions = Permission.objects.filter(name__in=('Can add course mode', 'Can change course mode'))
for permission in permissions:
self.user.user_permissions.add(permission)
self.assert_can_create_course()
@override_settings(EDX_API_KEY='edx')
def test_create_with_api_key(self):
""" Verify the view supports creating a course when authenticated with the API header key. """
self.client.logout()
self.assert_can_create_course(HTTP_X_EDX_API_KEY=settings.EDX_API_KEY)
""" API v1 URLs. """
from django.conf import settings
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'),
url(r'^{}/$'.format(settings.COURSE_ID_PATTERN), views.CourseRetrieveUpdateView.as_view(), name='retrieve_update'),
)
urlpatterns = patterns(
'',
url(r'^courses/', include(COURSE_URLS, namespace='courses')),
)
""" API v1 views. """
import logging
from django.http import Http404
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
from rest_framework.generics import RetrieveUpdateAPIView, ListAPIView
from rest_framework.permissions import IsAuthenticated
from commerce.api.v1.models import Course
from commerce.api.v1.permissions import ApiKeyOrModelPermission
from commerce.api.v1.serializers import CourseSerializer
from course_modes.models import CourseMode
log = logging.getLogger(__name__)
class CourseListView(ListAPIView):
""" List courses and modes. """
authentication_classes = (OAuth2Authentication, SessionAuthentication,)
permission_classes = (IsAuthenticated,)
serializer_class = CourseSerializer
def get_queryset(self):
return Course.iterator()
class CourseRetrieveUpdateView(RetrieveUpdateAPIView):
""" Retrieve, update, or create courses/modes. """
lookup_field = 'id'
lookup_url_kwarg = 'course_id'
model = CourseMode
authentication_classes = (OAuth2Authentication, SessionAuthentication,)
permission_classes = (ApiKeyOrModelPermission,)
serializer_class = CourseSerializer
def get_object(self, queryset=None):
course_id = self.kwargs.get(self.lookup_url_kwarg)
course = Course.get(course_id)
if course:
return course
raise Http404
......@@ -306,19 +306,6 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase)
self._test_successful_ecommerce_api_call(False)
class OrdersViewTests(BasketsViewTests):
"""
Ensures that /orders/ points to and behaves like /baskets/, for backward
compatibility with stale js clients during updates.
(XCOM-214) remove after release.
"""
def setUp(self):
super(OrdersViewTests, self).setUp()
self.url = reverse('commerce:orders')
@attr('shard_1')
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
class BasketOrderViewTests(UserMixin, TestCase):
......
......@@ -2,17 +2,16 @@
Defines the URL routes for this app.
"""
from django.conf.urls import patterns, url
from django.conf.urls import patterns, url, include
from commerce import views
BASKET_ID_PATTERN = r'(?P<basket_id>[\w]+)'
urlpatterns = patterns(
'',
# (XCOM-214) For backwards compatibility with js clients during intial release
url(r'^orders/$', views.BasketsView.as_view(), name="orders"),
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'^api/', include('commerce.api.urls', namespace='api'))
)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment