Add a course_modes REST API.
This commit is contained in:
committed by
Alex Dusenbery
parent
e14c5d69ec
commit
eff3a07005
0
common/djangoapps/course_modes/api/__init__.py
Normal file
0
common/djangoapps/course_modes/api/__init__.py
Normal file
57
common/djangoapps/course_modes/api/serializers.py
Normal file
57
common/djangoapps/course_modes/api/serializers.py
Normal 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
|
||||
11
common/djangoapps/course_modes/api/urls.py
Normal file
11
common/djangoapps/course_modes/api/urls.py
Normal 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')),
|
||||
]
|
||||
0
common/djangoapps/course_modes/api/v1/__init__.py
Normal file
0
common/djangoapps/course_modes/api/v1/__init__.py
Normal file
368
common/djangoapps/course_modes/api/v1/tests/test_views.py
Normal file
368
common/djangoapps/course_modes/api/v1/tests/test_views.py
Normal 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()
|
||||
23
common/djangoapps/course_modes/api/v1/urls.py
Normal file
23
common/djangoapps/course_modes/api/v1/urls.py
Normal 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'
|
||||
),
|
||||
]
|
||||
173
common/djangoapps/course_modes/api/v1/views.py
Normal file
173
common/djangoapps/course_modes/api/v1/views.py
Normal 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',
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user