Skip to content
Snippets Groups Projects
Commit 2854bb95 authored by Clinton Blackburn's avatar Clinton Blackburn
Browse files

Added Course Structure API

parent 2a8b7148
No related merge requests found
Showing
with 742 additions and 21 deletions
......@@ -33,7 +33,7 @@ class CountMongoCallsXMLRoundtrip(TestCase):
self.addCleanup(rmtree, self.export_dir, ignore_errors=True)
@ddt.data(
(MIXED_OLD_MONGO_MODULESTORE_BUILDER, 287, 780, 702, 702),
(MIXED_OLD_MONGO_MODULESTORE_BUILDER, 287, 779, 702, 702),
(MIXED_SPLIT_MODULESTORE_BUILDER, 37, 16, 190, 189),
)
@ddt.unpack
......
""" Course structure API """
""" This file is intentionally empty. """
"""
Course Structure API URI specification.
Patterns here should simply point to version-specific patterns.
"""
from django.conf.urls import patterns, url, include
urlpatterns = patterns(
'',
url(r'^v0/', include('course_structure_api.v0.urls', namespace='v0'))
)
""" Version 0 """
""" Django REST Framework Serializers """
from django.core.urlresolvers import reverse
from rest_framework import serializers
from courseware.courses import course_image_url
class CourseSerializer(serializers.Serializer):
""" Serializer for Courses """
id = serializers.CharField() # pylint: disable=invalid-name
name = serializers.CharField(source='display_name')
category = serializers.CharField()
org = serializers.SerializerMethodField('get_org')
run = serializers.SerializerMethodField('get_run')
course = serializers.SerializerMethodField('get_course')
uri = serializers.SerializerMethodField('get_uri')
image_url = serializers.SerializerMethodField('get_image_url')
start = serializers.DateTimeField()
end = serializers.DateTimeField()
def get_org(self, course):
""" Gets the course org """
return course.id.org
def get_run(self, course):
""" Gets the course run """
return course.id.run
def get_course(self, course):
""" Gets the course """
return course.id.course
def get_uri(self, course):
""" Builds course detail uri """
# pylint: disable=no-member
request = self.context['request']
return request.build_absolute_uri(reverse('course_structure_api:v0:detail', kwargs={'course_id': course.id}))
def get_image_url(self, course):
""" Get the course image URL """
return course_image_url(course)
class GradingPolicySerializer(serializers.Serializer):
""" Serializer for course grading policy. """
assignment_type = serializers.CharField(source='type')
count = serializers.IntegerField(source='min_count')
dropped = serializers.IntegerField(source='drop_count')
weight = serializers.FloatField()
# pylint: disable=invalid-name
class BlockSerializer(serializers.Serializer):
""" Serializer for course structure block. """
id = serializers.CharField(source='usage_key')
type = serializers.CharField(source='block_type')
display_name = serializers.CharField()
graded = serializers.BooleanField(default=False)
format = serializers.CharField()
children = serializers.CharField()
class CourseStructureSerializer(serializers.Serializer):
""" Serializer for course structure. """
root = serializers.CharField(source='root')
blocks = serializers.SerializerMethodField('get_blocks')
def get_blocks(self, structure):
""" Serialize the individual blocks. """
serialized = {}
for key, block in structure['blocks'].iteritems():
serialized[key] = BlockSerializer(block).data
return serialized
"""
Run these tests @ Devstack:
paver test_system -s lms --fasttest --verbose --test_id=lms/djangoapps/course_structure_api
"""
# pylint: disable=missing-docstring,invalid-name,maybe-no-member,attribute-defined-outside-init
from datetime import datetime
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
from openedx.core.djangoapps.content.course_structures.models import CourseStructure, update_course_structure
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from courseware.tests.factories import GlobalStaffFactory, StaffFactory
TEST_SERVER_HOST = 'http://testserver'
class CourseViewTestsMixin(object):
"""
Mixin for course view tests.
"""
view = None
def setUp(self):
super(CourseViewTestsMixin, self).setUp()
self.create_test_data()
self.create_user_and_access_token()
def create_user_and_access_token(self):
self.user = GlobalStaffFactory.create()
self.oauth_client = ClientFactory.create()
self.access_token = AccessTokenFactory.create(user=self.user, client=self.oauth_client).token
def create_test_data(self):
self.invalid_course_id = 'foo/bar/baz'
self.course = CourseFactory.create(display_name='An Introduction to API Testing', raw_grader=[
{
"min_count": 24,
"weight": 0.2,
"type": "Homework",
"drop_count": 0,
"short_label": "HW"
},
{
"min_count": 4,
"weight": 0.8,
"type": "Exam",
"drop_count": 0,
"short_label": "Exam"
}
])
self.course_id = unicode(self.course.id)
sequential = ItemFactory.create(
category="sequential",
parent_location=self.course.location,
display_name="Lesson 1",
format="Homework",
graded=True
)
ItemFactory.create(
category="problem",
parent_location=sequential.location,
display_name="Problem 1",
format="Homework"
)
self.empty_course = CourseFactory.create(
start=datetime(2014, 6, 16, 14, 30),
end=datetime(2015, 1, 16),
org="MTD"
)
def build_absolute_url(self, path=None):
""" Build absolute URL pointing to test server.
:param path: Path to append to the URL
"""
url = TEST_SERVER_HOST
if path:
url += path
return url
def assertValidResponseCourse(self, data, course):
""" Determines if the given response data (dict) matches the specified course. """
course_key = course.id
self.assertEqual(data['id'], unicode(course_key))
self.assertEqual(data['name'], course.display_name)
self.assertEqual(data['course'], course_key.course)
self.assertEqual(data['org'], course_key.org)
self.assertEqual(data['run'], course_key.run)
uri = self.build_absolute_url(
reverse('course_structure_api:v0:detail', kwargs={'course_id': unicode(course_key)}))
self.assertEqual(data['uri'], uri)
def http_get(self, uri, **headers):
"""Submit an HTTP GET request"""
default_headers = {
'HTTP_AUTHORIZATION': 'Bearer ' + self.access_token
}
default_headers.update(headers)
response = self.client.get(uri, follow=True, **default_headers)
return response
def test_not_authenticated(self):
"""
Verify that access is denied to non-authenticated users.
"""
raise NotImplementedError
def test_not_authorized(self):
"""
Verify that access is denied to non-authorized users.
"""
raise NotImplementedError
class CourseDetailMixin(object):
"""
Mixin for views utilizing only the course_id kwarg.
"""
def test_get_invalid_course(self):
"""
The view should return a 404 if the course ID is invalid.
"""
response = self.http_get(reverse(self.view, kwargs={'course_id': self.invalid_course_id}))
self.assertEqual(response.status_code, 404)
def test_get(self):
"""
The view should return a 200 if the course ID is valid.
"""
response = self.http_get(reverse(self.view, kwargs={'course_id': self.course_id}))
self.assertEqual(response.status_code, 200)
# Return the response so child classes do not have to repeat the request.
return response
def test_not_authenticated(self):
# If debug mode is enabled, the view should always return data.
with override_settings(DEBUG=True):
response = self.http_get(reverse(self.view, kwargs={'course_id': self.course_id}), HTTP_AUTHORIZATION=None)
self.assertEqual(response.status_code, 200)
# HTTP 401 should be returned if the user is not authenticated.
response = self.http_get(reverse(self.view, kwargs={'course_id': self.course_id}), HTTP_AUTHORIZATION=None)
self.assertEqual(response.status_code, 401)
def test_not_authorized(self):
user = StaffFactory(course_key=self.course.id)
access_token = AccessTokenFactory.create(user=user, client=self.oauth_client).token
auth_header = 'Bearer ' + access_token
# If debug mode is enabled, the view should always return data.
with override_settings(DEBUG=True):
response = self.http_get(reverse(self.view, kwargs={'course_id': self.course_id}),
HTTP_AUTHORIZATION=auth_header)
self.assertEqual(response.status_code, 200)
# Access should be granted if the proper access token is supplied.
response = self.http_get(reverse(self.view, kwargs={'course_id': self.course_id}),
HTTP_AUTHORIZATION=auth_header)
self.assertEqual(response.status_code, 200)
# Access should be denied if the user is not course staff.
response = self.http_get(reverse(self.view, kwargs={'course_id': unicode(self.empty_course.id)}),
HTTP_AUTHORIZATION=auth_header)
self.assertEqual(response.status_code, 403)
class CourseListTests(CourseViewTestsMixin, ModuleStoreTestCase):
view = 'course_structure_api:v0:list'
def test_get(self):
"""
The view should return a list of all courses.
"""
response = self.http_get(reverse(self.view))
self.assertEqual(response.status_code, 200)
data = response.data
courses = data['results']
self.assertEqual(len(courses), 2)
self.assertEqual(data['count'], 2)
self.assertEqual(data['num_pages'], 1)
self.assertValidResponseCourse(courses[0], self.empty_course)
self.assertValidResponseCourse(courses[1], self.course)
def test_get_with_pagination(self):
"""
The view should return a paginated list of courses.
"""
url = "{}?page_size=1".format(reverse(self.view))
response = self.http_get(url)
self.assertEqual(response.status_code, 200)
courses = response.data['results']
self.assertEqual(len(courses), 1)
self.assertValidResponseCourse(courses[0], self.empty_course)
def test_get_filtering(self):
"""
The view should return a list of details for the specified courses.
"""
url = "{}?course_id={}".format(reverse(self.view), self.course_id)
response = self.http_get(url)
self.assertEqual(response.status_code, 200)
courses = response.data['results']
self.assertEqual(len(courses), 1)
self.assertValidResponseCourse(courses[0], self.course)
def test_not_authenticated(self):
# If debug mode is enabled, the view should always return data.
with override_settings(DEBUG=True):
response = self.http_get(reverse(self.view), HTTP_AUTHORIZATION=None)
self.assertEqual(response.status_code, 200)
response = self.http_get(reverse(self.view), HTTP_AUTHORIZATION=None)
self.assertEqual(response.status_code, 401)
def test_not_authorized(self):
"""
Unauthorized users should get an empty list.
"""
user = StaffFactory(course_key=self.course.id)
access_token = AccessTokenFactory.create(user=user, client=self.oauth_client).token
auth_header = 'Bearer ' + access_token
# If debug mode is enabled, the view should always return data.
with override_settings(DEBUG=True):
response = self.http_get(reverse(self.view), HTTP_AUTHORIZATION=auth_header)
self.assertEqual(response.status_code, 200)
# Data should be returned if the user is authorized.
response = self.http_get(reverse(self.view), HTTP_AUTHORIZATION=auth_header)
self.assertEqual(response.status_code, 200)
url = "{}?course_id={}".format(reverse(self.view), self.course_id)
response = self.http_get(url, HTTP_AUTHORIZATION=auth_header)
self.assertEqual(response.status_code, 200)
data = response.data['results']
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['name'], self.course.display_name)
# The view should return an empty list if the user cannot access any courses.
url = "{}?course_id={}".format(reverse(self.view), unicode(self.empty_course.id))
response = self.http_get(url, HTTP_AUTHORIZATION=auth_header)
self.assertEqual(response.status_code, 200)
self.assertDictContainsSubset({'count': 0, u'results': []}, response.data)
class CourseDetailTests(CourseDetailMixin, CourseViewTestsMixin, ModuleStoreTestCase):
view = 'course_structure_api:v0:detail'
def test_get(self):
response = super(CourseDetailTests, self).test_get()
self.assertValidResponseCourse(response.data, self.course)
class CourseStructureTests(CourseDetailMixin, CourseViewTestsMixin, ModuleStoreTestCase):
view = 'course_structure_api:v0:structure'
def setUp(self):
super(CourseStructureTests, self).setUp()
# Ensure course structure exists for the course
update_course_structure(self.course.id)
def test_get(self):
"""
If the course structure exists in the database, the view should return the data. Otherwise, the view should
initiate an asynchronous course structure generation and return a 503.
"""
# Attempt to retrieve data for a course without stored structure
CourseStructure.objects.all().delete()
self.assertFalse(CourseStructure.objects.filter(course_id=self.course.id).exists())
response = self.http_get(reverse(self.view, kwargs={'course_id': self.course_id}))
self.assertEqual(response.status_code, 503)
self.assertEqual(response['Retry-After'], '120')
# Course structure generation shouldn't take long. Generate the data and try again.
self.assertTrue(CourseStructure.objects.filter(course_id=self.course.id).exists())
response = self.http_get(reverse(self.view, kwargs={'course_id': self.course_id}))
self.assertEqual(response.status_code, 200)
blocks = {}
def add_block(xblock):
children = xblock.get_children()
blocks[unicode(xblock.location)] = {
u'id': unicode(xblock.location),
u'type': xblock.category,
u'display_name': xblock.display_name,
u'format': xblock.format,
u'graded': xblock.graded,
u'children': [unicode(child.location) for child in children]
}
for child in children:
add_block(child)
course = self.store.get_course(self.course.id, depth=None)
add_block(course)
expected = {
u'root': unicode(self.course.location),
u'blocks': blocks
}
self.maxDiff = None
self.assertDictEqual(response.data, expected)
class CourseGradingPolicyTests(CourseDetailMixin, CourseViewTestsMixin, ModuleStoreTestCase):
view = 'course_structure_api:v0:grading_policy'
def test_get(self):
"""
The view should return grading policy for a course.
"""
response = super(CourseGradingPolicyTests, self).test_get()
expected = [
{
"count": 24,
"weight": 0.2,
"assignment_type": "Homework",
"dropped": 0
},
{
"count": 4,
"weight": 0.8,
"assignment_type": "Exam",
"dropped": 0
}
]
self.assertListEqual(response.data, expected)
"""
Courses Structure API v0 URI specification
"""
from django.conf import settings
from django.conf.urls import patterns, url
from course_structure_api.v0 import views
COURSE_ID_PATTERN = settings.COURSE_ID_PATTERN
urlpatterns = patterns(
'',
url(r'^courses/$', views.CourseList.as_view(), name='list'),
url(r'^courses/{}/$'.format(COURSE_ID_PATTERN), views.CourseDetail.as_view(), name='detail'),
url(r'^course_structures/{}/$'.format(COURSE_ID_PATTERN), views.CourseStructure.as_view(), name='structure'),
url(r'^grading_policies/{}/$'.format(COURSE_ID_PATTERN), views.CourseGradingPolicy.as_view(), name='grading_policy')
)
""" API implementation for course-oriented interactions. """
import logging
from operator import attrgetter
from django.conf import settings
from django.http import Http404
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
from rest_framework.exceptions import PermissionDenied, AuthenticationFailed
from rest_framework.generics import RetrieveAPIView, ListAPIView
from rest_framework.response import Response
from xmodule.modulestore.django import modulestore
from opaque_keys.edx.keys import CourseKey
from courseware.access import has_access
from openedx.core.djangoapps.content.course_structures import models
from openedx.core.lib.api.permissions import IsAuthenticatedOrDebug
from openedx.core.lib.api.serializers import PaginationSerializer
from courseware import courses
from course_structure_api.v0 import serializers
from student.roles import CourseInstructorRole, CourseStaffRole
log = logging.getLogger(__name__)
class CourseViewMixin(object):
"""
Mixin for views dealing with course content. Also handles authorization and authentication.
"""
lookup_field = 'course_id'
authentication_classes = (OAuth2Authentication, SessionAuthentication,)
permission_classes = (IsAuthenticatedOrDebug,)
def get_course_or_404(self):
"""
Retrieves the specified course, or raises an Http404 error if it does not exist.
Also checks to ensure the user has permissions to view the course
"""
try:
course_id = self.kwargs.get('course_id')
course_key = CourseKey.from_string(course_id)
course = courses.get_course(course_key)
self.check_course_permissions(self.request.user, course)
return course
except ValueError:
raise Http404
def user_can_access_course(self, user, course):
"""
Determines if the user is staff or an instructor for the course.
Always returns True if DEBUG mode is enabled.
"""
return (settings.DEBUG
or has_access(user, CourseStaffRole.ROLE, course)
or has_access(user, CourseInstructorRole.ROLE, course))
def check_course_permissions(self, user, course):
"""
Checks if the request user can access the course.
Raises PermissionDenied if the user does not have course access.
"""
if not self.user_can_access_course(user, course):
raise PermissionDenied
def perform_authentication(self, request):
"""
Ensures that the user is authenticated (e.g. not an AnonymousUser), unless DEBUG mode is enabled.
"""
super(CourseViewMixin, self).perform_authentication(request)
if request.user.is_anonymous() and not settings.DEBUG:
raise AuthenticationFailed
class CourseList(CourseViewMixin, ListAPIView):
"""
**Use Case**
CourseList returns paginated list of courses in the edX Platform. The list can be filtered by course_id.
**Example Request**
GET /api/course_structure/v0/courses/
GET /api/course_structure/v0/courses/?course_id={course_id1},{course_id2}
**Response Values**
* id: The unique identifier for the course.
* name: The name of the course.
* category: The type of content. In this case, the value is always "course".
* org: The organization specified for the course.
* course: The course number.
* org: The run for the course.
* uri: The URI to use to get details of the course.
* image_url: The URI for the course's main image.
* start: Course start date
* end: Course end date
"""
paginate_by = 10
paginate_by_param = 'page_size'
pagination_serializer_class = PaginationSerializer
serializer_class = serializers.CourseSerializer
def get_queryset(self):
course_ids = self.request.QUERY_PARAMS.get('course_id', None)
course_descriptors = []
if course_ids:
course_ids = course_ids.split(',')
for course_id in course_ids:
course_key = CourseKey.from_string(course_id)
course_descriptor = courses.get_course(course_key)
course_descriptors.append(course_descriptor)
else:
course_descriptors = modulestore().get_courses()
results = [course for course in course_descriptors if self.user_can_access_course(self.request.user, course)]
# Sort the results in a predictable manner.
results.sort(key=attrgetter('id'))
return results
class CourseDetail(CourseViewMixin, RetrieveAPIView):
"""
**Use Case**
CourseDetail returns details for a course.
**Example requests**:
GET /api/course_structure/v0/courses/{course_id}/
**Response Values**
* category: The type of content.
* name: The name of the course.
* uri: The URI to use to get details of the course.
* course: The course number.
* due: The due date. For courses, the value is always null.
* org: The organization specified for the course.
* id: The unique identifier for the course.
"""
serializer_class = serializers.CourseSerializer
def get_object(self, queryset=None):
return self.get_course_or_404()
class CourseStructure(CourseViewMixin, RetrieveAPIView):
"""
**Use Case**
Retrieves course structure.
**Example requests**:
GET /api/course_structure/v0/course_structures/{course_id}/
**Response Values**
* root: ID of the root node of the structure
* blocks: Dictionary mapping IDs to block nodes.
"""
serializer_class = serializers.CourseStructureSerializer
course = None
def retrieve(self, request, *args, **kwargs):
try:
return super(CourseStructure, self).retrieve(request, *args, **kwargs)
except models.CourseStructure.DoesNotExist:
# If we don't have data stored, generate it and return a 503.
models.update_course_structure.delay(self.course.id)
return Response(status=503, headers={'Retry-After': '120'})
def get_object(self, queryset=None):
# Make sure the course exists and the user has permissions to view it.
self.course = self.get_course_or_404()
course_structure = models.CourseStructure.objects.get(course_id=self.course.id)
return course_structure.structure
class CourseGradingPolicy(CourseViewMixin, ListAPIView):
"""
**Use Case**
Retrieves course grading policy.
**Example requests**:
GET /api/course_structure/v0/grading_policies/{course_id}/
**Response Values**
* assignment_type: The type of the assignment (e.g. Exam, Homework). Note: These values are course-dependent.
Do not make any assumptions based on assignment type.
* count: Number of assignments of the type.
* dropped: Number of assignments of the type that are dropped.
* weight: Effect of the assignment type on grading.
"""
serializer_class = serializers.GradingPolicySerializer
allow_empty = False
def get_queryset(self):
course = self.get_course_or_404()
# Return the raw data. The serializer will handle the field mappings.
return course.raw_grader
......@@ -3,7 +3,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
from notification_prefs import NOTIFICATION_PREF_KEY
from notifier_api.serializers import NotifierUserSerializer
from openedx.core.djangoapps.user_api.views import ApiKeyHeaderPermission
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
class NotifierUsersViewSet(ReadOnlyModelViewSet):
......
......@@ -1627,6 +1627,7 @@ INSTALLED_APPS = (
'lms.djangoapps.lms_xblock',
'openedx.core.djangoapps.content.course_structures',
'course_structure_api',
)
######################### MARKETING SITE ###############################
......
......@@ -92,6 +92,8 @@ CC_PROCESSOR = {
}
########################### External REST APIs #################################
FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
OAUTH_OIDC_ISSUER = 'http://127.0.0.1:8000/oauth2'
FEATURES['ENABLE_MOBILE_REST_API'] = True
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
......
......@@ -81,6 +81,8 @@ urlpatterns = ('', # nopep8
# Courseware search endpoints
url(r'^search/', include('search.urls')),
# Course content API
url(r'^api/course_structure/', include('course_structure_api.urls', namespace='course_structure_api')),
)
if settings.FEATURES["ENABLE_COMBINED_LOGIN_REGISTRATION"]:
......@@ -115,7 +117,6 @@ urlpatterns += (
url(r'^course_modes/', include('course_modes.urls')),
)
js_info_dict = {
'domain': 'djangojs',
# We need to explicitly include external Django apps that are not in LOCALE_PATHS.
......
"""HTTP end-points for the User API. """
import copy
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
from opaque_keys import InvalidKeyError
import third_party_auth
......@@ -15,7 +16,6 @@ from opaque_keys.edx import locator
from rest_framework import authentication
from rest_framework import filters
from rest_framework import generics
from rest_framework import permissions
from rest_framework import status
from rest_framework import viewsets
from rest_framework.views import APIView
......@@ -32,23 +32,6 @@ from .models import UserPreference, UserProfile
from .serializers import UserSerializer, UserPreferenceSerializer
class ApiKeyHeaderPermission(permissions.BasePermission):
def has_permission(self, request, view):
"""
Check for permissions by matching the configured API key and header
If settings.DEBUG is True and settings.EDX_API_KEY is not set or None,
then allow the request. Otherwise, allow the request if and only if
settings.EDX_API_KEY is set and the X-Edx-Api-Key HTTP header is
present in the request and matches the setting.
"""
api_key = getattr(settings, "EDX_API_KEY", None)
return (
(settings.DEBUG and api_key is None) or
(api_key is not None and request.META.get("HTTP_X_EDX_API_KEY") == api_key)
)
class LoginSessionView(APIView):
"""HTTP end-points for logging in users. """
......
"""
Package for common functionality shared across various APIs.
"""
from django.conf import settings
from rest_framework import permissions
from rest_framework.exceptions import PermissionDenied
class ApiKeyHeaderPermission(permissions.BasePermission):
def has_permission(self, request, view):
"""
Check for permissions by matching the configured API key and header
If settings.DEBUG is True and settings.EDX_API_KEY is not set or None,
then allow the request. Otherwise, allow the request if and only if
settings.EDX_API_KEY is set and the X-Edx-Api-Key HTTP header is
present in the request and matches the setting.
"""
api_key = getattr(settings, "EDX_API_KEY", None)
return (
(settings.DEBUG and api_key is None) or
(api_key is not None and request.META.get("HTTP_X_EDX_API_KEY") == api_key)
)
class IsAuthenticatedOrDebug(permissions.BasePermission):
"""
Allows access only to authenticated users, or anyone if debug mode is enabled.
"""
def has_permission(self, request, view):
if settings.DEBUG:
return True
user = getattr(request, 'user', None)
return user and user.is_authenticated()
from rest_framework import pagination, serializers
class PaginationSerializer(pagination.PaginationSerializer):
"""
Custom PaginationSerializer to include num_pages field
"""
num_pages = serializers.Field(source='paginator.num_pages')
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