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
from ccx_keys.locator import CCXLocator
from django.conf import settings
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.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
......@@ -785,8 +785,14 @@ def _create_or_rerun_course(request):
if 'source_course_key' in request.json:
return _rerun_course(request, org, course, run, fields)
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:
return JsonResponse({
'ErrMsg': _(
......@@ -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.
Returns the URL for the course overview page.
Raises DuplicateCourseError if the course already exists
Create a new course run.
Raises:
DuplicateCourseError: Course run already exists.
"""
org_data = get_organization_by_short_name(org)
if not org_data and organizations_enabled():
return JsonResponse(
{'error': _('You must link this course to an organization in order to continue. '
'Organization you selected does not exist in the system, '
'you will need to add it to the system')},
status=400
)
raise ValidationError(_('You must link this course to an organization in order to continue. Organization '
'you selected does not exist in the system, you will need to add it to the system'))
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)
return JsonResponse({
'url': reverse_course_url('course_handler', new_course.id),
'course_key': unicode(new_course.id),
})
return new_course
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/'
# 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.
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 #########################################
......@@ -1077,6 +1077,7 @@ INSTALLED_APPS = [
# DRF filters
'django_filters',
'cms.djangoapps.api',
]
......
......@@ -13,7 +13,6 @@ LMS_BASE = 'edx.devstack.lms:18000'
CMS_BASE = 'edx.devstack.studio:18010'
LMS_ROOT_URL = 'http://{}'.format(LMS_BASE)
FEATURES.update({
'ENABLE_COURSEWARE_INDEX': False,
'ENABLE_LIBRARY_INDEX': False,
......@@ -21,3 +20,11 @@ FEATURES.update({
})
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(
# For redirecting to help pages.
url(r'^help_token/', include('help_tokens.urls')),
url(r'^api/', include('cms.djangoapps.api.urls', namespace='api')),
)
# restful api
......
......@@ -72,6 +72,7 @@ class CcxRestApiTest(CcxTestCase, APITestCase):
instructor = UserFactory()
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.course.enable_ccx = True
......@@ -89,7 +90,7 @@ class CcxRestApiTest(CcxTestCase, APITestCase):
'client_id': app_client.client_id,
'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)
token_resp_json = json.loads(token_resp.content)
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