Commit 25684995 by Clinton Blackburn Committed by Clinton Blackburn

Added Studio API endpoint to support course creation

LEARNER-2468
parent f1b91cb8
default_app_config = 'api.apps.ApiConfig'
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = 'cms.djangoapps.api'
verbose_name = 'API'
from django.conf.urls import include, url
urlpatterns = [
url(r'^v1/', include('cms.djangoapps.api.v1.urls', namespace='v1')),
]
import six
from django.contrib.auth import get_user_model
from django.db import transaction
from rest_framework import serializers
from rest_framework.fields import get_attribute
from cms.djangoapps.contentstore.views.course import create_new_course
from student.models import CourseAccessRole
from xmodule.modulestore.django import modulestore
User = get_user_model()
class CourseAccessRoleSerializer(serializers.ModelSerializer):
user = serializers.SlugRelatedField(slug_field='username', queryset=User.objects.all())
class Meta:
model = CourseAccessRole
fields = ('user', 'role',)
class CourseRunScheduleSerializer(serializers.Serializer):
start = serializers.DateTimeField()
end = serializers.DateTimeField()
enrollment_start = serializers.DateTimeField()
enrollment_end = serializers.DateTimeField(allow_null=True)
class CourseRunTeamSerializer(serializers.Serializer):
def to_internal_value(self, data):
return CourseAccessRoleSerializer(data=data, many=True).to_internal_value(data)
def to_representation(self, instance):
roles = CourseAccessRole.objects.filter(course_id=instance.id)
return CourseAccessRoleSerializer(roles, many=True).data
def get_attribute(self, instance):
# Course instances have no "team" attribute. Return the course, and the consuming serializer will
# handle the rest.
return instance
class CourseRunSerializer(serializers.Serializer):
id = serializers.CharField(read_only=True)
title = serializers.CharField(source='display_name')
schedule = CourseRunScheduleSerializer(source='*', required=False)
team = CourseRunTeamSerializer(required=False)
def update(self, instance, validated_data):
team = validated_data.pop('team', [])
with transaction.atomic():
self.update_team(instance, team)
for attr, value in six.iteritems(validated_data):
setattr(instance, attr, value)
modulestore().update_item(instance, self.context['request'].user.id)
return instance
def update_team(self, instance, team):
CourseAccessRole.objects.filter(course_id=instance.id).delete()
# TODO In the future we can optimize by getting users in a single query.
CourseAccessRole.objects.bulk_create([
CourseAccessRole(course_id=instance.id, role=member['role'], user=User.objects.get(username=member['user']))
for member in team
])
class CourseRunCreateSerializer(CourseRunSerializer):
org = serializers.CharField(source='id.org')
number = serializers.CharField(source='id.course')
run = serializers.CharField(source='id.run')
def create(self, validated_data):
_id = validated_data.pop('id')
team = validated_data.pop('team', [])
user = self.context['request'].user
with transaction.atomic():
instance = create_new_course(user, _id['org'], _id['course'], _id['run'], validated_data)
self.update_team(instance, team)
return instance
import datetime
import pytz
from student.roles import CourseInstructorRole, CourseStaffRole
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..utils import serialize_datetime
from ...serializers.course_runs import CourseRunSerializer
class CourseRunSerializerTests(ModuleStoreTestCase):
def test_data(self):
start = datetime.datetime.now(pytz.UTC)
end = start + datetime.timedelta(days=30)
enrollment_start = start - datetime.timedelta(days=7)
enrollment_end = end - datetime.timedelta(days=14)
course = CourseFactory(start=start, end=end, enrollment_start=enrollment_start, enrollment_end=enrollment_end)
instructor = UserFactory()
CourseInstructorRole(course.id).add_users(instructor)
staff = UserFactory()
CourseStaffRole(course.id).add_users(staff)
serializer = CourseRunSerializer(course)
expected = {
'id': str(course.id),
'title': course.display_name,
'schedule': {
'start': serialize_datetime(start),
'end': serialize_datetime(end),
'enrollment_start': serialize_datetime(enrollment_start),
'enrollment_end': serialize_datetime(enrollment_end),
},
'team': [
{
'user': instructor.username,
'role': 'instructor',
},
{
'user': staff.username,
'role': 'staff',
},
],
}
assert serializer.data == expected
import datetime
import pytz
from django.core.urlresolvers import reverse
from opaque_keys.edx.keys import CourseKey
from rest_framework.test import APIClient
from student.models import CourseAccessRole
from student.tests.factories import AdminFactory, TEST_PASSWORD, UserFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..utils import serialize_datetime
from ...serializers.course_runs import CourseRunSerializer
class CourseRunViewSetTests(ModuleStoreTestCase):
list_url = reverse('api:v1:course_run-list')
def setUp(self):
super(CourseRunViewSetTests, self).setUp()
self.client = APIClient()
user = AdminFactory()
self.client.login(username=user.username, password=TEST_PASSWORD)
def assert_course_run_schedule(self, course_run, start, end, enrollment_start, enrollment_end):
assert course_run.start == start
assert course_run.end == end
assert course_run.enrollment_start == enrollment_start
assert course_run.enrollment_end == enrollment_end
def test_without_authentication(self):
self.client.logout()
response = self.client.get(self.list_url)
assert response.status_code == 401
def test_without_authorization(self):
user = UserFactory(is_staff=False)
self.client.login(username=user.username, password=TEST_PASSWORD)
response = self.client.get(self.list_url)
assert response.status_code == 403
def test_list(self):
course_runs = CourseFactory.create_batch(3)
response = self.client.get(self.list_url)
assert response.status_code == 200
# Order matters for the assertion
course_runs = sorted(course_runs, key=lambda course_run: str(course_run.id))
actual = sorted(response.data, key=lambda course_run: course_run['id'])
assert actual == CourseRunSerializer(course_runs, many=True).data
def test_retrieve(self):
course_run = CourseFactory()
url = reverse('api:v1:course_run-detail', kwargs={'pk': str(course_run.id)})
response = self.client.get(url)
assert response.status_code == 200
assert response.data == CourseRunSerializer(course_run).data
def test_retrieve_not_found(self):
url = reverse('api:v1:course_run-detail', kwargs={'pk': 'course-v1:TestX+Test101x+1T2017'})
response = self.client.get(url)
assert response.status_code == 404
def test_update_not_found(self):
url = reverse('api:v1:course_run-detail', kwargs={'pk': 'course-v1:TestX+Test101x+1T2017'})
response = self.client.put(url, {})
assert response.status_code == 404
def test_update(self):
course_run = CourseFactory(start=None, end=None, enrollment_start=None, enrollment_end=None)
assert CourseAccessRole.objects.filter(course_id=course_run.id).count() == 0
url = reverse('api:v1:course_run-detail', kwargs={'pk': str(course_run.id)})
start = datetime.datetime.now(pytz.UTC).replace(microsecond=0)
end = start + datetime.timedelta(days=30)
enrollment_start = start - datetime.timedelta(days=7)
enrollment_end = end - datetime.timedelta(days=14)
title = 'A New Testing Strategy'
user = UserFactory()
role = 'staff'
data = {
'title': title,
'schedule': {
'start': serialize_datetime(start),
'end': serialize_datetime(end),
'enrollment_start': serialize_datetime(enrollment_start),
'enrollment_end': serialize_datetime(enrollment_end),
},
'team': [
{
'user': user.username,
'role': role,
}
],
}
response = self.client.put(url, data, format='json')
assert response.status_code == 200
# An error will be raised if the endpoint doesn't create the role
CourseAccessRole.objects.get(course_id=course_run.id, user=user, role=role)
assert CourseAccessRole.objects.filter(course_id=course_run.id).count() == 1
course_run = modulestore().get_course(course_run.id)
assert response.data == CourseRunSerializer(course_run).data
assert course_run.display_name == title
self.assert_course_run_schedule(course_run, start, end, enrollment_start, enrollment_end)
def test_update_with_invalid_user(self):
course_run = CourseFactory()
url = reverse('api:v1:course_run-detail', kwargs={'pk': str(course_run.id)})
data = {
'title': course_run.display_name,
'team': [
{
'user': 'test-user',
'role': 'staff',
}
]
}
response = self.client.put(url, data, format='json')
assert response.status_code == 400
assert response.data == {'team': [{'user': ['Object with username=test-user does not exist.']}]}
def test_partial_update(self):
start = datetime.datetime.now(pytz.UTC).replace(microsecond=0)
course_run = CourseFactory(start=start, end=None, enrollment_start=None, enrollment_end=None)
assert CourseAccessRole.objects.filter(course_id=course_run.id).count() == 0
user = UserFactory()
role = 'staff'
data = {
'team': [
{
'user': user.username,
'role': role,
}
],
}
url = reverse('api:v1:course_run-detail', kwargs={'pk': str(course_run.id)})
response = self.client.patch(url, data, format='json')
assert response.status_code == 200
# An error will be raised if the endpoint doesn't create the role
CourseAccessRole.objects.get(course_id=course_run.id, user=user, role=role)
assert CourseAccessRole.objects.filter(course_id=course_run.id).count() == 1
course_run = modulestore().get_course(course_run.id)
self.assert_course_run_schedule(course_run, start, None, None, None)
def test_create(self):
start = datetime.datetime.now(pytz.UTC).replace(microsecond=0)
end = start + datetime.timedelta(days=30)
enrollment_start = start - datetime.timedelta(days=7)
enrollment_end = end - datetime.timedelta(days=14)
user = UserFactory()
role = 'staff'
data = {
'title': 'Testing 101',
'org': 'TestingX',
'number': 'Testing101x',
'run': '3T2017',
'schedule': {
'start': serialize_datetime(start),
'end': serialize_datetime(end),
'enrollment_start': serialize_datetime(enrollment_start),
'enrollment_end': serialize_datetime(enrollment_end),
},
'team': [
{
'user': user.username,
'role': role,
}
],
}
response = self.client.post(self.list_url, data, format='json')
assert response.status_code == 201
course_run_key = CourseKey.from_string(response.data['id'])
course_run = modulestore().get_course(course_run_key)
assert course_run.display_name == data['title']
assert course_run.id.org == data['org']
assert course_run.id.course == data['number']
assert course_run.id.run == data['run']
self.assert_course_run_schedule(course_run, start, end, enrollment_start, enrollment_end)
# An error will be raised if the endpoint doesn't create the role
CourseAccessRole.objects.get(course_id=course_run.id, user=user, role=role)
assert CourseAccessRole.objects.filter(course_id=course_run.id).count() == 1
def serialize_datetime(d):
return d.strftime('%Y-%m-%dT%H:%M:%S.%fZ')
from rest_framework.routers import DefaultRouter
from .views.course_runs import CourseRunViewSet
router = DefaultRouter()
router.register(r'course_runs', CourseRunViewSet, base_name='course_run')
urlpatterns = router.urls
from django.conf import settings
from django.http import Http404
from edx_rest_framework_extensions.authentication import JwtAuthentication
from opaque_keys.edx.keys import CourseKey
from rest_framework import permissions, status, viewsets
from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from contentstore.views.course import _accessible_courses_iter, get_course_and_check_access
from ..serializers.course_runs import CourseRunCreateSerializer, CourseRunSerializer
class CourseRunViewSet(viewsets.ViewSet):
authentication_classes = (JwtAuthentication, SessionAuthentication,)
lookup_value_regex = settings.COURSE_KEY_REGEX
permission_classes = (permissions.IsAdminUser,)
serializer_class = CourseRunSerializer
def get_course_run_or_raise_404(self, course_run_key, user):
course_run = get_course_and_check_access(course_run_key, user)
if course_run:
return course_run
raise Http404
def get_serializer_context(self):
return {
'request': self.request,
'format': self.format_kwarg,
'view': self
}
def get_serializer(self, *args, **kwargs):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def list(self, request):
course_runs, __ = _accessible_courses_iter(request)
serializer = self.get_serializer(course_runs, many=True)
return Response(serializer.data)
def retrieve(self, request, pk=None):
course_run_key = CourseKey.from_string(pk)
course_run = self.get_course_run_or_raise_404(course_run_key, request.user)
serializer = self.get_serializer(course_run)
return Response(serializer.data)
def update(self, request, pk=None, *args, **kwargs):
course_run_key = CourseKey.from_string(pk)
course_run = self.get_course_run_or_raise_404(course_run_key, request.user)
partial = kwargs.pop('partial', False)
serializer = self.get_serializer(course_run, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
def partial_update(self, request, *args, **kwargs):
kwargs['partial'] = True
return self.update(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
serializer = CourseRunCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
...@@ -12,7 +12,7 @@ import six ...@@ -12,7 +12,7 @@ import six
from ccx_keys.locator import CCXLocator from ccx_keys.locator import CCXLocator
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied, ValidationError
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect from django.shortcuts import redirect
...@@ -785,8 +785,14 @@ def _create_or_rerun_course(request): ...@@ -785,8 +785,14 @@ def _create_or_rerun_course(request):
if 'source_course_key' in request.json: if 'source_course_key' in request.json:
return _rerun_course(request, org, course, run, fields) return _rerun_course(request, org, course, run, fields)
else: else:
return _create_new_course(request, org, course, run, fields) try:
new_course = create_new_course(request.user, org, course, run, fields)
return JsonResponse({
'url': reverse_course_url('course_handler', new_course.id),
'course_key': unicode(new_course.id),
})
except ValidationError as ex:
return JsonResponse({'error': ex.message}, status=400)
except DuplicateCourseError: except DuplicateCourseError:
return JsonResponse({ return JsonResponse({
'ErrMsg': _( 'ErrMsg': _(
...@@ -807,27 +813,21 @@ def _create_or_rerun_course(request): ...@@ -807,27 +813,21 @@ def _create_or_rerun_course(request):
) )
def _create_new_course(request, org, number, run, fields): def create_new_course(user, org, number, run, fields):
""" """
Create a new course. Create a new course run.
Returns the URL for the course overview page.
Raises DuplicateCourseError if the course already exists Raises:
DuplicateCourseError: Course run already exists.
""" """
org_data = get_organization_by_short_name(org) org_data = get_organization_by_short_name(org)
if not org_data and organizations_enabled(): if not org_data and organizations_enabled():
return JsonResponse( raise ValidationError(_('You must link this course to an organization in order to continue. Organization '
{'error': _('You must link this course to an organization in order to continue. ' 'you selected does not exist in the system, you will need to add it to the system'))
'Organization you selected does not exist in the system, '
'you will need to add it to the system')},
status=400
)
store_for_new_course = modulestore().default_modulestore.get_modulestore_type() store_for_new_course = modulestore().default_modulestore.get_modulestore_type()
new_course = create_new_course_in_store(store_for_new_course, request.user, org, number, run, fields) new_course = create_new_course_in_store(store_for_new_course, user, org, number, run, fields)
add_organization_course(org_data, new_course.id) add_organization_course(org_data, new_course.id)
return JsonResponse({ return new_course
'url': reverse_course_url('course_handler', new_course.id),
'course_key': unicode(new_course.id),
})
def create_new_course_in_store(store, user, org, number, run, fields): def create_new_course_in_store(store, user, org, number, run, fields):
......
...@@ -368,7 +368,7 @@ ENTERPRISE_CONSENT_API_URL = LMS_ROOT_URL + '/consent/api/v1/' ...@@ -368,7 +368,7 @@ ENTERPRISE_CONSENT_API_URL = LMS_ROOT_URL + '/consent/api/v1/'
# These are standard regexes for pulling out info like course_ids, usage_ids, etc. # These are standard regexes for pulling out info like course_ids, usage_ids, etc.
# They are used so that URLs with deprecated-format strings still work. # They are used so that URLs with deprecated-format strings still work.
from lms.envs.common import ( from lms.envs.common import (
COURSE_KEY_PATTERN, COURSE_ID_PATTERN, USAGE_KEY_PATTERN, ASSET_KEY_PATTERN COURSE_KEY_PATTERN, COURSE_KEY_REGEX, COURSE_ID_PATTERN, USAGE_KEY_PATTERN, ASSET_KEY_PATTERN
) )
######################### CSRF ######################################### ######################### CSRF #########################################
...@@ -1077,6 +1077,7 @@ INSTALLED_APPS = [ ...@@ -1077,6 +1077,7 @@ INSTALLED_APPS = [
# DRF filters # DRF filters
'django_filters', 'django_filters',
'cms.djangoapps.api',
] ]
......
...@@ -13,7 +13,6 @@ LMS_BASE = 'edx.devstack.lms:18000' ...@@ -13,7 +13,6 @@ LMS_BASE = 'edx.devstack.lms:18000'
CMS_BASE = 'edx.devstack.studio:18010' CMS_BASE = 'edx.devstack.studio:18010'
LMS_ROOT_URL = 'http://{}'.format(LMS_BASE) LMS_ROOT_URL = 'http://{}'.format(LMS_BASE)
FEATURES.update({ FEATURES.update({
'ENABLE_COURSEWARE_INDEX': False, 'ENABLE_COURSEWARE_INDEX': False,
'ENABLE_LIBRARY_INDEX': False, 'ENABLE_LIBRARY_INDEX': False,
...@@ -21,3 +20,11 @@ FEATURES.update({ ...@@ -21,3 +20,11 @@ FEATURES.update({
}) })
CREDENTIALS_SERVICE_USERNAME = 'credentials_worker' CREDENTIALS_SERVICE_USERNAME = 'credentials_worker'
OAUTH_OIDC_ISSUER = '{}/oauth2'.format(LMS_ROOT_URL)
JWT_AUTH.update({
'JWT_SECRET_KEY': 'lms-secret',
'JWT_ISSUER': OAUTH_OIDC_ISSUER,
'JWT_AUDIENCE': 'lms-key',
})
...@@ -71,6 +71,7 @@ urlpatterns = patterns( ...@@ -71,6 +71,7 @@ urlpatterns = patterns(
# For redirecting to help pages. # For redirecting to help pages.
url(r'^help_token/', include('help_tokens.urls')), url(r'^help_token/', include('help_tokens.urls')),
url(r'^api/', include('cms.djangoapps.api.urls', namespace='api')),
) )
# restful api # restful api
......
...@@ -72,6 +72,7 @@ class CcxRestApiTest(CcxTestCase, APITestCase): ...@@ -72,6 +72,7 @@ class CcxRestApiTest(CcxTestCase, APITestCase):
instructor = UserFactory() instructor = UserFactory()
allow_access(self.course, instructor, 'instructor') allow_access(self.course, instructor, 'instructor')
# FIXME: Testing for multiple authentication types in multiple test cases is overkill. Stop it!
self.auth, self.auth_header_oauth2_provider = self.prepare_auth_token(app_user) self.auth, self.auth_header_oauth2_provider = self.prepare_auth_token(app_user)
self.course.enable_ccx = True self.course.enable_ccx = True
...@@ -89,7 +90,7 @@ class CcxRestApiTest(CcxTestCase, APITestCase): ...@@ -89,7 +90,7 @@ class CcxRestApiTest(CcxTestCase, APITestCase):
'client_id': app_client.client_id, 'client_id': app_client.client_id,
'client_secret': app_client.client_secret 'client_secret': app_client.client_secret
} }
token_resp = self.client.post('/oauth2/access_token/', data=token_data) token_resp = self.client.post(reverse('oauth2:access_token'), data=token_data, format='multipart')
self.assertEqual(token_resp.status_code, status.HTTP_200_OK) self.assertEqual(token_resp.status_code, status.HTTP_200_OK)
token_resp_json = json.loads(token_resp.content) token_resp_json = json.loads(token_resp.content)
return '{token_type} {token}'.format( return '{token_type} {token}'.format(
......
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