Add a course_modes REST API.

This commit is contained in:
Alex Dusenbery
2019-04-01 16:07:12 -04:00
committed by Alex Dusenbery
parent e14c5d69ec
commit eff3a07005
9 changed files with 633 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
"""
Course modes API serializers.
"""
from rest_framework import serializers
from course_modes.models import CourseMode
class CourseModeSerializer(serializers.Serializer):
"""
Serializer for the CourseMode model.
The ``course_id``, ``mode_slug``, ``mode_display_name``, and ``currency`` fields
are all required for a create operation. Neither the ``course_id``
nor the ``mode_slug`` fields can be modified during an update operation.
"""
UNCHANGEABLE_FIELDS = {'course_id', 'mode_slug'}
course_id = serializers.CharField()
mode_slug = serializers.CharField()
mode_display_name = serializers.CharField()
min_price = serializers.IntegerField(required=False)
currency = serializers.CharField()
expiration_datetime = serializers.DateTimeField(required=False)
expiration_datetime_is_explicit = serializers.BooleanField(required=False)
description = serializers.CharField(required=False)
sku = serializers.CharField(required=False)
bulk_sku = serializers.CharField(required=False)
def create(self, validated_data):
"""
This method must be implemented for use in our
ListCreateAPIView.
"""
return CourseMode.objects.create(**validated_data)
def update(self, instance, validated_data):
"""
This method must be implemented for use in our
RetrieveUpdateDestroyAPIView.
"""
errors = {}
for field in validated_data:
if field in self.UNCHANGEABLE_FIELDS:
errors[field] = ['This field cannot be modified.']
if errors:
raise serializers.ValidationError(errors)
for modifiable_field in validated_data:
setattr(
instance,
modifiable_field,
validated_data.get(modifiable_field, getattr(instance, modifiable_field))
)
instance.save()
return instance

View File

@@ -0,0 +1,11 @@
"""
URL definitions for the course_modes API.
"""
from django.conf.urls import include, url
app_name = 'common.djangoapps.course_modes.api'
urlpatterns = [
url(r'^v1/', include('course_modes.api.v1.urls', namespace='v1')),
]

View File

@@ -0,0 +1,368 @@
"""
Tests for the course modes API.
"""
from __future__ import absolute_import, unicode_literals
from itertools import product
import json
import unittest
import ddt
from django.conf import settings
from django.urls import reverse
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from rest_framework.test import APITestCase
from six import text_type
from course_modes.api.v1.views import CourseModesView
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES
from openedx.core.djangoapps.user_authn.tests.utils import AuthAndScopesTestMixin, AuthType, JWT_AUTH_TYPES
from student.tests.factories import UserFactory
@ddt.ddt
class CourseModesViewTestBase(AuthAndScopesTestMixin):
"""
Tests for the course modes list/create API endpoints.
"""
default_scopes = CourseModesView.required_scopes
view_name = ''
@classmethod
def setUpClass(cls):
cls.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course')
cls.course = CourseOverviewFactory.create(id=cls.course_key)
cls.audit_mode = CourseModeFactory.create(
course_id=cls.course_key,
mode_slug='audit',
mode_display_name='Audit',
min_price=0,
)
cls.verified_mode = CourseModeFactory.create(
course_id=cls.course_key,
mode_slug='verified',
mode_display_name='Verified',
min_price=25,
)
@classmethod
def tearDownClass(cls):
cls.audit_mode.delete()
cls.verified_mode.delete()
def setUp(self):
super(CourseModesViewTestBase, self).setUp()
# overwrite self.student to be a staff member, since only staff
# should be able to access the course_modes API endpoints.
# This is needed to make a handful of tests inherited from AuthAndScopesTestMixin pass.
# Note that we also inherit here self.global_staff (a staff user)
# and self.other_student, which remains a non-staff user.
self.student = UserFactory.create(password=self.user_password, is_staff=True)
def assert_success_response_for_student(self, response):
"""
Required method to implement AuthAndScopesTestMixin.
"""
# TODO
pass
@ddt.data(*product(JWT_AUTH_TYPES, (True, False)))
@ddt.unpack
def test_jwt_on_behalf_of_user(self, auth_type, scopes_enforced):
"""
We have to override this super method due to this API
being restricted to staff users only.
"""
with ENFORCE_JWT_SCOPES.override(active=scopes_enforced):
jwt_token = self._create_jwt_token(self.student, auth_type, include_me_filter=True)
# include_me_filter=True means a JWT filter will require the username
# of the requesting user to be in the requested URL
url = self.get_url(self.student) + '?username={}'.format(self.student.username)
resp = self.get_response(AuthType.jwt, token=jwt_token, url=url)
assert status.HTTP_200_OK == resp.status_code
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class TestCourseModesListViews(CourseModesViewTestBase, APITestCase):
"""
Tests for the course modes list/create API endpoints.
"""
view_name = 'course_modes_api:v1:course_modes_list'
# pylint: disable=unused-argument
def get_url(self, username=None, course_id=None):
"""
Required method to implement AuthAndScopesTestMixin.
"""
kwargs = {
'course_id': text_type(course_id or self.course_key)
}
return reverse(self.view_name, kwargs=kwargs)
def test_list_course_modes_student_forbidden(self):
self.client.login(username=self.other_student.username, password=self.user_password)
url = self.get_url(course_id=self.course_key)
response = self.client.get(url)
assert status.HTTP_403_FORBIDDEN == response.status_code
def test_list_course_modes_happy_path(self):
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(course_id=self.course_key)
response = self.client.get(url)
assert status.HTTP_200_OK == response.status_code
actual_results = sorted(
[dict(item) for item in response.data],
key=lambda item: item['mode_slug'],
)
expected_results = [
{
'course_id': text_type(self.course_key),
'mode_slug': 'audit',
'mode_display_name': 'Audit',
'min_price': 0,
'currency': 'usd',
'expiration_datetime': None,
'expiration_datetime_is_explicit': False,
'description': None,
'sku': None,
'bulk_sku': None,
},
{
'course_id': text_type(self.course_key),
'mode_slug': 'verified',
'mode_display_name': 'Verified',
'min_price': 25,
'currency': 'usd',
'expiration_datetime': None,
'expiration_datetime_is_explicit': False,
'description': None,
'sku': None,
'bulk_sku': None,
},
]
assert expected_results == actual_results
def test_post_course_mode_forbidden(self):
self.client.login(username=self.other_student.username, password=self.user_password)
url = self.get_url(course_id=self.course_key)
response = self.client.post(url, data={'it': 'does not matter'})
assert status.HTTP_403_FORBIDDEN == response.status_code
def test_post_course_mode_happy_path(self):
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(course_id=self.course_key)
request_payload = {
'course_id': text_type(self.course_key),
'mode_slug': 'masters',
'mode_display_name': 'Masters',
'currency': 'usd',
}
response = self.client.post(url, data=request_payload)
assert status.HTTP_201_CREATED == response.status_code
new_mode = CourseMode.objects.get(course_id=self.course_key, mode_slug='masters')
assert self.course_key == new_mode.course_id
assert 'masters' == new_mode.mode_slug
assert 'Masters' == new_mode.mode_display_name
assert 0 == new_mode.min_price # 0 is the default defined on the models.CourseMode.currency field
assert 'usd' == new_mode.currency
def test_post_course_mode_fails_when_missing_required_fields(self):
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(course_id=self.course_key)
request_payload = {
'course_id': text_type(self.course_key),
'mode_slug': 'phd',
}
response = self.client.post(url, data=request_payload)
assert status.HTTP_400_BAD_REQUEST == response.status_code
expected_data = {
'currency': [
'This field is required.'
],
'mode_display_name': [
'This field is required.'
]
}
assert expected_data == response.data
assert 0 == CourseMode.objects.filter(course_id=self.course_key, mode_slug='phd').count()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class TestCourseModesDetailViews(CourseModesViewTestBase, APITestCase):
"""
Tests for the course modes retrieve/update/delete API endpoints.
"""
view_name = 'course_modes_api:v1:course_modes_detail'
# pylint: disable=unused-argument
def get_url(self, username=None, course_id=None, mode_slug=None):
"""
Required method to implement AuthAndScopesTestMixin.
"""
kwargs = {
'course_id': text_type(course_id or self.course_key),
'mode_slug': mode_slug or 'audit',
}
return reverse(self.view_name, kwargs=kwargs)
def test_retrieve_course_mode_student_forbidden(self):
self.client.login(username=self.other_student.username, password=self.user_password)
url = self.get_url(mode_slug='audit')
response = self.client.get(url)
assert status.HTTP_403_FORBIDDEN == response.status_code
def test_retrieve_course_mode_does_not_exist(self):
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(mode_slug='does-not-exist')
response = self.client.get(url)
assert status.HTTP_404_NOT_FOUND == response.status_code
def test_retrieve_course_mode_happy_path(self):
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(mode_slug='audit')
response = self.client.get(url)
assert status.HTTP_200_OK == response.status_code
actual_data = dict(response.data)
expected_data = {
'course_id': text_type(self.course_key),
'mode_slug': 'audit',
'mode_display_name': 'Audit',
'min_price': 0,
'currency': 'usd',
'expiration_datetime': None,
'expiration_datetime_is_explicit': False,
'description': None,
'sku': None,
'bulk_sku': None,
}
assert expected_data == actual_data
def test_update_course_mode_student_forbidden(self):
self.client.login(username=self.other_student.username, password=self.user_password)
url = self.get_url(mode_slug='audit')
response = self.client.patch(
url,
content_type='application/merge-patch+json',
data=json.dumps({'it': 'does not matter'}),
)
assert status.HTTP_403_FORBIDDEN == response.status_code
def test_update_course_mode_does_not_exist(self):
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(mode_slug='does-not-exist')
response = self.client.patch(
url,
data=json.dumps({'it': 'does not matter'}),
content_type='application/merge-patch+json',
)
assert status.HTTP_404_NOT_FOUND == response.status_code
def test_update_course_mode_happy_path(self):
_ = CourseModeFactory.create(
course_id=self.course_key,
mode_slug='prof-ed',
mode_display_name='Professional Education',
min_price=100,
currency='jpy',
)
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(mode_slug='prof-ed')
response = self.client.patch(
url,
data=json.dumps({
'min_price': 222,
'mode_display_name': 'Something Else',
}),
content_type='application/merge-patch+json',
)
assert status.HTTP_204_NO_CONTENT == response.status_code
updated_mode = CourseMode.objects.get(course_id=self.course_key, mode_slug='prof-ed')
assert 222 == updated_mode.min_price
assert 'Something Else' == updated_mode.mode_display_name
assert 'jpy' == updated_mode.currency
def test_update_course_mode_fails_when_updating_static_fields(self):
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(mode_slug='audit')
response = self.client.patch(
url,
data=json.dumps({
'course_id': 'course-v1:edX+DemoX+Demo_Course2',
'mode_slug': 'audit-2',
}),
content_type='application/merge-patch+json',
)
assert status.HTTP_400_BAD_REQUEST == response.status_code
expected_data = {
'course_id': [
'This field cannot be modified.'
],
'mode_slug': [
'This field cannot be modified.'
]
}
assert expected_data == response.data
assert 'audit' == self.audit_mode.mode_slug
assert self.course_key == self.audit_mode.course_id
def test_delete_course_mode_student_forbidden(self):
self.client.login(username=self.other_student.username, password=self.user_password)
url = self.get_url(mode_slug='audit')
response = self.client.delete(url)
assert status.HTTP_403_FORBIDDEN == response.status_code
def test_delete_course_mode_does_not_exist(self):
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(mode_slug='does-not-exist')
response = self.client.delete(url)
assert status.HTTP_404_NOT_FOUND == response.status_code
def test_delete_course_mode_happy_path(self):
_ = CourseModeFactory.create(
course_id=self.course_key,
mode_slug='bachelors',
mode_display_name='Bachelors',
min_price=1000,
)
self.client.login(username=self.global_staff.username, password=self.user_password)
url = self.get_url(mode_slug='bachelors')
response = self.client.delete(url)
assert status.HTTP_204_NO_CONTENT == response.status_code
assert 0 == CourseMode.objects.filter(course_id=self.course_key, mode_slug='bachelors').count()

View File

@@ -0,0 +1,23 @@
"""
URL definitions for the course_modes v1 API.
"""
from django.conf import settings
from django.conf.urls import url
from course_modes.api.v1 import views
app_name = 'v1'
urlpatterns = [
url(
r'^courses/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN),
views.CourseModesView.as_view(),
name='course_modes_list'
),
url(
r'^courses/{course_id}/(?P<mode_slug>.*)$'.format(course_id=settings.COURSE_ID_PATTERN),
views.CourseModesDetailView.as_view(),
name='course_modes_detail'
),
]

View File

@@ -0,0 +1,173 @@
"""
Defines the "ReSTful" API for course modes.
"""
import logging
from django.shortcuts import get_object_or_404
from edx_rest_framework_extensions import permissions
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView
from rest_framework.response import Response
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from openedx.core.lib.api.parsers import MergePatchParser
from course_modes.api.serializers import CourseModeSerializer
from course_modes.models import CourseMode
log = logging.getLogger(__name__)
class CourseModesMixin(object):
"""
A base class for course modes views that specifies authentication, permissions,
serialization, pagination, and the base queryset.
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
# When not considering JWT conditions, this permission class grants access
# to any authenticated client that is staff. When consider JWT, the client
# must be granted access to this resource via their JWT scopes.
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
required_scopes = ['course_modes:read']
serializer_class = CourseModeSerializer
pagination_class = None
lookup_field = 'course_id'
queryset = CourseMode.objects.all()
class CourseModesView(CourseModesMixin, ListCreateAPIView):
"""
View to list or create course modes for a course.
**Use Case**
List all course modes for a course, or create a new
course mode.
**Example Requests**
GET /api/course_modes/v1/courses/{course_id}/
Returns a list of all existing course modes for a course.
POST /api/course_modes/v1/courses/{course_id}/
Creates a new course mode in a course.
**Response Values**
For each HTTP verb below, an HTTP 404 "Not Found" response is returned if the
requested course id does not exist.
GET: If the request is successful, an HTTP 200 "OK" response is returned
along with a list of course mode dictionaries within a course.
The details are contained in a JSON dictionary as follows:
* course_id: The course identifier.
* mode_slug: The short name for the course mode.
* mode_display_name: The verbose name for the course mode.
* min_price: The minimum price for which a user can
enroll in this mode.
* currency: The currency of the listed prices.
* expiration_datetime: The date and time after which
users cannot enroll in the course in this mode (not required for POST).
* expiration_datetime_is_explicit: Whether the expiration_datetime field was
explicitly set (not required for POST).
* description: A description of this mode (not required for POST).
* sku: The SKU for this mode (for ecommerce purposes, not required for POST).
* bulk_sku: The bulk SKU for this mode (for ecommerce purposes, not required for POST).
POST: If the request is successful, an HTTP 201 "Created" response is returned.
"""
pass
class CourseModesDetailView(CourseModesMixin, RetrieveUpdateDestroyAPIView):
"""
View to retrieve, update, or delete a specific course mode for a course.
**Use Case**
Get or update course mode details for a specific course mode on a course.
Or you may delete a specific course mode from a course.
**Example Requests**
GET /api/course_modes/v1/courses/{course_id}/{mode_slug}
Returns details on an existing course mode for a course.
PATCH /api/course_modes/v1/courses/{course_id}/{mode_slug}
Updates (via merge) details of an existing course mode for a course.
DELETE /api/course_modes/v1/courses/{course_id}/{mode_slug}
Deletes an existing course mode for a course.
**Response Values**
For each HTTP verb below, an HTTP 404 "Not Found" response is returned if the
requested course id does not exist, or the mode slug does not exist within the course.
GET: If the request is successful, an HTTP 200 "OK" response is returned
along with a details for a single course mode within a course. The details are contained
in a JSON dictionary as follows:
* course_id: The course identifier.
* mode_slug: The short name for the course mode.
* mode_display_name: The verbose name for the course mode.
* min_price: The minimum price for which a user can
enroll in this mode.
* currency: The currency of the listed prices.
* expiration_datetime: The date and time after which
users cannot enroll in the course in this mode (not required for PATCH).
* expiration_datetime_is_explicit: Whether the expiration_datetime field was
explicitly set (not required for PATCH).
* description: A description of this mode (not required for PATCH).
* sku: The SKU for this mode (for ecommerce purposes, not required for PATCH).
* bulk_sku: The bulk SKU for this mode (for ecommerce purposes, not required for PATCH).
PATCH: If the request is successful, an HTTP 204 "No Content" response is returned.
If "application/merge-patch+json" is not the specified content type,
a 415 "Unsupported Media Type" response is returned.
DELETE: If the request is successful, an HTTP 204 "No Content" response is returned.
"""
http_method_names = ['get', 'patch', 'delete', 'head', 'options']
parser_classes = (MergePatchParser,)
multiple_lookup_fields = ('course_id', 'mode_slug')
def get_object(self):
queryset = self.get_queryset()
query_filter = {}
for field in self.multiple_lookup_fields:
query_filter[field] = self.kwargs[field]
query_filter['course_id'] = CourseKey.from_string(query_filter['course_id'])
obj = get_object_or_404(queryset, **query_filter)
self.check_object_permissions(self.request, obj)
return obj
def patch(self, request, *args, **kwargs):
"""
Performs a partial update of a CourseMode instance.
"""
course_mode = self.get_object()
serializer = self.serializer_class(course_mode, data=request.data, partial=True)
if serializer.is_valid(raise_exception=True):
serializer.save() # can also raise ValidationError
return Response(
status=status.HTTP_204_NO_CONTENT,
content_type='application/json',
)

View File

@@ -128,6 +128,7 @@ urlpatterns = [
# Multiple course modes and identity verification
url(r'^course_modes/', include('course_modes.urls')),
url(r'^api/course_modes/', include('course_modes.api.urls', namespace='course_modes_api')),
url(r'^verify_student/', include('verify_student.urls')),
# URLs for managing dark launches of languages