diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo_call_count.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo_call_count.py index 660cc41612..0046fdd7ed 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo_call_count.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo_call_count.py @@ -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 diff --git a/lms/djangoapps/course_structure_api/__init__.py b/lms/djangoapps/course_structure_api/__init__.py new file mode 100644 index 0000000000..bbc526f894 --- /dev/null +++ b/lms/djangoapps/course_structure_api/__init__.py @@ -0,0 +1 @@ +""" Course structure API """ diff --git a/lms/djangoapps/course_structure_api/models.py b/lms/djangoapps/course_structure_api/models.py new file mode 100644 index 0000000000..0ef6cb32f6 --- /dev/null +++ b/lms/djangoapps/course_structure_api/models.py @@ -0,0 +1 @@ +""" This file is intentionally empty. """ diff --git a/lms/djangoapps/course_structure_api/urls.py b/lms/djangoapps/course_structure_api/urls.py new file mode 100644 index 0000000000..f97dde0ee4 --- /dev/null +++ b/lms/djangoapps/course_structure_api/urls.py @@ -0,0 +1,11 @@ +""" +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')) +) diff --git a/lms/djangoapps/course_structure_api/v0/__init__.py b/lms/djangoapps/course_structure_api/v0/__init__.py new file mode 100644 index 0000000000..eee3a676f4 --- /dev/null +++ b/lms/djangoapps/course_structure_api/v0/__init__.py @@ -0,0 +1 @@ +""" Version 0 """ diff --git a/lms/djangoapps/course_structure_api/v0/serializers.py b/lms/djangoapps/course_structure_api/v0/serializers.py new file mode 100644 index 0000000000..877f556779 --- /dev/null +++ b/lms/djangoapps/course_structure_api/v0/serializers.py @@ -0,0 +1,76 @@ +""" 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 diff --git a/lms/djangoapps/course_structure_api/v0/tests.py b/lms/djangoapps/course_structure_api/v0/tests.py new file mode 100644 index 0000000000..88f4e02ecd --- /dev/null +++ b/lms/djangoapps/course_structure_api/v0/tests.py @@ -0,0 +1,350 @@ +""" +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) diff --git a/lms/djangoapps/course_structure_api/v0/urls.py b/lms/djangoapps/course_structure_api/v0/urls.py new file mode 100644 index 0000000000..2ccc7db7b1 --- /dev/null +++ b/lms/djangoapps/course_structure_api/v0/urls.py @@ -0,0 +1,18 @@ +""" +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') +) diff --git a/lms/djangoapps/course_structure_api/v0/views.py b/lms/djangoapps/course_structure_api/v0/views.py new file mode 100644 index 0000000000..73b7083d27 --- /dev/null +++ b/lms/djangoapps/course_structure_api/v0/views.py @@ -0,0 +1,232 @@ +""" 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 diff --git a/lms/djangoapps/notifier_api/views.py b/lms/djangoapps/notifier_api/views.py index 9be79b0717..b8e05ebb16 100644 --- a/lms/djangoapps/notifier_api/views.py +++ b/lms/djangoapps/notifier_api/views.py @@ -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): diff --git a/lms/envs/common.py b/lms/envs/common.py index 64971438ff..df132589b2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1627,6 +1627,7 @@ INSTALLED_APPS = ( 'lms.djangoapps.lms_xblock', 'openedx.core.djangoapps.content.course_structures', + 'course_structure_api', ) ######################### MARKETING SITE ############################### diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 4606d6f302..4e067f0bc4 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -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 diff --git a/lms/urls.py b/lms/urls.py index a3b604d756..10e7e1de12 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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. diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 22aabf0189..eaf7828d02 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -1,5 +1,6 @@ """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. """ diff --git a/openedx/core/lib/__init__.py b/openedx/core/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/lib/api/__init__.py b/openedx/core/lib/api/__init__.py new file mode 100644 index 0000000000..328a8227f4 --- /dev/null +++ b/openedx/core/lib/api/__init__.py @@ -0,0 +1,3 @@ +""" +Package for common functionality shared across various APIs. +""" diff --git a/openedx/core/lib/api/permissions.py b/openedx/core/lib/api/permissions.py new file mode 100644 index 0000000000..dca589d88b --- /dev/null +++ b/openedx/core/lib/api/permissions.py @@ -0,0 +1,33 @@ +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() diff --git a/openedx/core/lib/api/serializers.py b/openedx/core/lib/api/serializers.py new file mode 100644 index 0000000000..5e83e2f666 --- /dev/null +++ b/openedx/core/lib/api/serializers.py @@ -0,0 +1,8 @@ +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')