Merge pull request #17983 from edx/arch/remove-course-structure-api
Remove Course Structure API
This commit is contained in:
@@ -1 +0,0 @@
|
||||
""" Course structure API """
|
||||
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
Course Structure API URI specification.
|
||||
|
||||
Patterns here should simply point to version-specific patterns.
|
||||
"""
|
||||
from django.conf.urls import include, url
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^v0/', include('course_structure_api.v0.urls', namespace='v0'))
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
""" Version 0 """
|
||||
@@ -1,41 +0,0 @@
|
||||
""" Django REST Framework Serializers """
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from rest_framework import serializers
|
||||
|
||||
from openedx.core.lib.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()
|
||||
run = serializers.SerializerMethodField()
|
||||
course = serializers.SerializerMethodField()
|
||||
uri = serializers.SerializerMethodField()
|
||||
image_url = serializers.SerializerMethodField()
|
||||
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 """
|
||||
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)
|
||||
@@ -1,453 +0,0 @@
|
||||
"""
|
||||
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 edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
|
||||
from mock import Mock, patch
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||
from courseware.tests.factories import GlobalStaffFactory, StaffFactory
|
||||
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
|
||||
from openedx.core.djangoapps.content.course_structures.tasks import update_course_structure
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.modulestore.xml import CourseLocationManager
|
||||
from xmodule.tests import get_test_system
|
||||
|
||||
TEST_SERVER_HOST = 'http://testserver'
|
||||
|
||||
|
||||
class CourseViewTestsMixin(object):
|
||||
"""
|
||||
Mixin for course view tests.
|
||||
"""
|
||||
view = None
|
||||
|
||||
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"
|
||||
}
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super(CourseViewTestsMixin, self).setUp()
|
||||
self.create_user_and_access_token()
|
||||
|
||||
def create_user(self):
|
||||
self.user = GlobalStaffFactory.create()
|
||||
|
||||
def create_user_and_access_token(self):
|
||||
self.create_user()
|
||||
self.oauth_client = ClientFactory.create()
|
||||
self.access_token = AccessTokenFactory.create(user=self.user, client=self.oauth_client).token
|
||||
|
||||
@classmethod
|
||||
def create_course_data(cls):
|
||||
cls.invalid_course_id = 'foo/bar/baz'
|
||||
cls.course = CourseFactory.create(display_name='An Introduction to API Testing', raw_grader=cls.raw_grader)
|
||||
cls.course_id = unicode(cls.course.id)
|
||||
with cls.store.bulk_operations(cls.course.id, emit_signals=False):
|
||||
cls.sequential = ItemFactory.create(
|
||||
category="sequential",
|
||||
parent_location=cls.course.location,
|
||||
display_name="Lesson 1",
|
||||
format="Homework",
|
||||
graded=True
|
||||
)
|
||||
|
||||
factory = MultipleChoiceResponseXMLFactory()
|
||||
args = {'choices': [False, True, False]}
|
||||
problem_xml = factory.build_xml(**args)
|
||||
cls.problem = ItemFactory.create(
|
||||
category="problem",
|
||||
parent_location=cls.sequential.location,
|
||||
display_name="Problem 1",
|
||||
format="Homework",
|
||||
data=problem_xml,
|
||||
)
|
||||
|
||||
cls.video = ItemFactory.create(
|
||||
category="video",
|
||||
parent_location=cls.sequential.location,
|
||||
display_name="Video 1",
|
||||
)
|
||||
|
||||
cls.html = ItemFactory.create(
|
||||
category="html",
|
||||
parent_location=cls.sequential.location,
|
||||
display_name="HTML 1",
|
||||
)
|
||||
|
||||
cls.empty_course = CourseFactory.create(
|
||||
start=datetime(2014, 6, 16, 14, 30),
|
||||
end=datetime(2015, 1, 16),
|
||||
org="MTD",
|
||||
# Use mongo so that we can get a test with the deprecated key format.
|
||||
default_store=ModuleStoreEnum.Type.mongo
|
||||
)
|
||||
|
||||
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 http_get_for_course(self, course_id=None, **headers):
|
||||
"""Submit an HTTP GET request to the view for the given course"""
|
||||
|
||||
return self.http_get(
|
||||
reverse(self.view, kwargs={'course_id': course_id or self.course_id}),
|
||||
**headers
|
||||
)
|
||||
|
||||
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 CourseDetailTestMixin(object):
|
||||
"""
|
||||
Mixin for views utilizing only the course_id kwarg.
|
||||
"""
|
||||
view_supports_debug_mode = True
|
||||
|
||||
def test_get_invalid_course(self):
|
||||
"""
|
||||
The view should return a 404 if the course ID is invalid.
|
||||
"""
|
||||
response = self.http_get_for_course(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_for_course()
|
||||
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):
|
||||
""" The view should return HTTP status 401 if no user is authenticated. """
|
||||
# HTTP 401 should be returned if the user is not authenticated.
|
||||
response = self.http_get_for_course(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
|
||||
|
||||
# Access should be granted if the proper access token is supplied.
|
||||
response = self.http_get_for_course(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_for_course(course_id=unicode(self.empty_course.id), HTTP_AUTHORIZATION=auth_header)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class CourseListTests(CourseViewTestsMixin, SharedModuleStoreTestCase):
|
||||
view = 'course_structure_api:v0:list'
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CourseListTests, cls).setUpClass()
|
||||
cls.create_course_data()
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
def test_course_error(self):
|
||||
"""
|
||||
Ensure the view still returns results even if get_courses() returns an ErrorDescriptor. The ErrorDescriptor
|
||||
should be filtered out.
|
||||
"""
|
||||
|
||||
error_descriptor = ErrorDescriptor.from_xml(
|
||||
'<course></course>',
|
||||
get_test_system(),
|
||||
CourseLocationManager(CourseLocator(org='org', course='course', run='run')),
|
||||
None
|
||||
)
|
||||
|
||||
descriptors = [error_descriptor, self.empty_course, self.course]
|
||||
|
||||
with patch('xmodule.modulestore.mixed.MixedModuleStore.get_courses', Mock(return_value=descriptors)):
|
||||
self.test_get()
|
||||
|
||||
|
||||
class CourseDetailTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
|
||||
view = 'course_structure_api:v0:detail'
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CourseDetailTests, cls).setUpClass()
|
||||
cls.create_course_data()
|
||||
|
||||
def test_get(self):
|
||||
response = super(CourseDetailTests, self).test_get()
|
||||
self.assertValidResponseCourse(response.data, self.course)
|
||||
|
||||
|
||||
class CourseStructureTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
|
||||
view = 'course_structure_api:v0:structure'
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CourseStructureTests, cls).setUpClass()
|
||||
cls.create_course_data()
|
||||
|
||||
def setUp(self):
|
||||
super(CourseStructureTests, self).setUp()
|
||||
|
||||
# Ensure course structure exists for the course
|
||||
update_course_structure(unicode(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_for_course()
|
||||
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_for_course()
|
||||
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'parent': None,
|
||||
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(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
|
||||
view = 'course_structure_api:v0:grading_policy'
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CourseGradingPolicyTests, cls).setUpClass()
|
||||
cls.create_course_data()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class CourseGradingPolicyMissingFieldsTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
|
||||
view = 'course_structure_api:v0:grading_policy'
|
||||
|
||||
# Update the raw grader to have missing keys
|
||||
raw_grader = [
|
||||
{
|
||||
"min_count": 24,
|
||||
"weight": 0.2,
|
||||
"type": "Homework",
|
||||
"drop_count": 0,
|
||||
"short_label": "HW"
|
||||
},
|
||||
{
|
||||
# Deleted "min_count" key
|
||||
"weight": 0.8,
|
||||
"type": "Exam",
|
||||
"drop_count": 0,
|
||||
"short_label": "Exam"
|
||||
}
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CourseGradingPolicyMissingFieldsTests, cls).setUpClass()
|
||||
cls.create_course_data()
|
||||
|
||||
def test_get(self):
|
||||
"""
|
||||
The view should return grading policy for a course.
|
||||
"""
|
||||
response = super(CourseGradingPolicyMissingFieldsTests, self).test_get()
|
||||
|
||||
expected = [
|
||||
{
|
||||
"count": 24,
|
||||
"weight": 0.2,
|
||||
"assignment_type": "Homework",
|
||||
"dropped": 0
|
||||
},
|
||||
{
|
||||
"count": None,
|
||||
"weight": 0.8,
|
||||
"assignment_type": "Exam",
|
||||
"dropped": 0
|
||||
}
|
||||
]
|
||||
self.assertListEqual(response.data, expected)
|
||||
@@ -1,20 +0,0 @@
|
||||
"""
|
||||
Courses Structure API v0 URI specification
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from course_structure_api.v0 import views
|
||||
|
||||
COURSE_ID_PATTERN = settings.COURSE_ID_PATTERN
|
||||
|
||||
urlpatterns = [
|
||||
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'
|
||||
),
|
||||
]
|
||||
@@ -1,293 +0,0 @@
|
||||
""" API implementation for course-oriented interactions. """
|
||||
|
||||
import logging
|
||||
|
||||
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.authentication import SessionAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.generics import RetrieveAPIView, ListAPIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_oauth.authentication import OAuth2Authentication
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from course_structure_api.v0 import serializers
|
||||
from courseware import courses
|
||||
from courseware.access import has_access
|
||||
from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors
|
||||
from openedx.core.lib.exceptions import CourseNotFoundError
|
||||
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 = (JwtAuthentication, OAuth2Authentication, SessionAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
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_key)
|
||||
|
||||
return course
|
||||
except ValueError:
|
||||
raise Http404
|
||||
|
||||
@staticmethod
|
||||
def course_check(func):
|
||||
"""Decorator responsible for catching errors finding and returning a 404 if the user does not have access
|
||||
to the API function.
|
||||
|
||||
:param func: function to be wrapped
|
||||
:returns: the wrapped function
|
||||
"""
|
||||
|
||||
def func_wrapper(self, *args, **kwargs):
|
||||
"""Wrapper function for this decorator.
|
||||
|
||||
:param *args: the arguments passed into the function
|
||||
:param **kwargs: the keyword arguments passed into the function
|
||||
:returns: the result of the wrapped function
|
||||
"""
|
||||
try:
|
||||
course_id = self.kwargs.get('course_id')
|
||||
self.course_key = CourseKey.from_string(course_id)
|
||||
self.check_course_permissions(self.request.user, self.course_key)
|
||||
return func(self, *args, **kwargs)
|
||||
except CourseNotFoundError:
|
||||
raise Http404
|
||||
|
||||
return func_wrapper
|
||||
|
||||
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 bool(
|
||||
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 404 if the user does not have course access.
|
||||
"""
|
||||
if not self.user_can_access_course(user, course):
|
||||
raise Http404
|
||||
|
||||
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**
|
||||
|
||||
Get a paginated list of courses in the edX Platform.
|
||||
|
||||
The list can be filtered by course_id.
|
||||
|
||||
Each page in the list can contain up to 10 courses.
|
||||
|
||||
**Example Requests**
|
||||
|
||||
GET /api/course_structure/v0/courses/
|
||||
|
||||
GET /api/course_structure/v0/courses/?course_id={course_id1},{course_id2}
|
||||
|
||||
**Response Values**
|
||||
|
||||
* count: The number of courses in the edX platform.
|
||||
|
||||
* next: The URI to the next page of courses.
|
||||
|
||||
* previous: The URI to the previous page of courses.
|
||||
|
||||
* num_pages: The number of pages listing courses.
|
||||
|
||||
* results: A list of courses returned. Each collection in the list
|
||||
contains these fields.
|
||||
|
||||
* 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.
|
||||
|
||||
* run: The run of the course.
|
||||
|
||||
* course: The course number.
|
||||
|
||||
* uri: The URI to use to get details of the course.
|
||||
|
||||
* image_url: The URI for the course's main image.
|
||||
|
||||
* start: The course start date.
|
||||
|
||||
* end: The course end date. If course end date is not specified, the
|
||||
value is null.
|
||||
"""
|
||||
serializer_class = serializers.CourseSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
course_ids = self.request.query_params.get('course_id', None)
|
||||
|
||||
results = []
|
||||
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)
|
||||
results.append(course_descriptor)
|
||||
else:
|
||||
results = modulestore().get_courses()
|
||||
|
||||
# Ensure only course descriptors are returned.
|
||||
results = (course for course in results if course.scope_ids.block_type == 'course')
|
||||
|
||||
# Ensure only courses accessible by the user are returned.
|
||||
results = (course for course in results if self.user_can_access_course(self.request.user, course))
|
||||
|
||||
# Sort the results in a predictable manner.
|
||||
return sorted(results, key=lambda course: unicode(course.id))
|
||||
|
||||
|
||||
class CourseDetail(CourseViewMixin, RetrieveAPIView):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
Get details for a specific course.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
GET /api/course_structure/v0/courses/{course_id}/
|
||||
|
||||
**Response Values**
|
||||
|
||||
* id: The unique identifier for the course.
|
||||
|
||||
* name: The name of the course.
|
||||
|
||||
* category: The type of content.
|
||||
|
||||
* org: The organization that is offering the course.
|
||||
|
||||
* run: The run of the course.
|
||||
|
||||
* course: The course number.
|
||||
|
||||
* uri: The URI to use to get details about the course.
|
||||
|
||||
* image_url: The URI for the course's main image.
|
||||
|
||||
* start: The course start date.
|
||||
|
||||
* end: The course end date. If course end date is not specified, the
|
||||
value is null.
|
||||
"""
|
||||
serializer_class = serializers.CourseSerializer
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return self.get_course_or_404()
|
||||
|
||||
|
||||
class CourseStructure(CourseViewMixin, RetrieveAPIView):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
Get the course structure. This endpoint returns all blocks in the
|
||||
course.
|
||||
|
||||
**Example requests**:
|
||||
|
||||
GET /api/course_structure/v0/course_structures/{course_id}/
|
||||
|
||||
**Response Values**
|
||||
|
||||
* root: The ID of the root node of the course structure.
|
||||
|
||||
* blocks: A dictionary that maps block IDs to a collection of
|
||||
information about each block. Each block contains the following
|
||||
fields.
|
||||
|
||||
* id: The ID of the block.
|
||||
|
||||
* type: The type of block. Possible values include sequential,
|
||||
vertical, html, problem, video, and discussion. The type can also be
|
||||
the name of a custom type of block used for the course.
|
||||
|
||||
* display_name: The display name configured for the block.
|
||||
|
||||
* graded: Whether or not the sequential or problem is graded. The
|
||||
value is true or false.
|
||||
|
||||
* format: The assignment type.
|
||||
|
||||
* children: If the block has child blocks, a list of IDs of the child
|
||||
blocks in the order they appear in the course.
|
||||
"""
|
||||
|
||||
@CourseViewMixin.course_check
|
||||
def get(self, request, **kwargs):
|
||||
try:
|
||||
return Response(api.course_structure(self.course_key))
|
||||
except errors.CourseStructureNotAvailableError:
|
||||
# If we don't have data stored, we will try to regenerate it, so
|
||||
# return a 503 and as them to retry in 2 minutes.
|
||||
return Response(status=503, headers={'Retry-After': '120'})
|
||||
|
||||
|
||||
class CourseGradingPolicy(CourseViewMixin, ListAPIView):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
Get the course grading policy.
|
||||
|
||||
**Example requests**:
|
||||
|
||||
GET /api/course_structure/v0/grading_policies/{course_id}/
|
||||
|
||||
**Response Values**
|
||||
|
||||
* assignment_type: The type of the assignment, as configured by course
|
||||
staff. For example, course staff might make the assignment types Homework,
|
||||
Quiz, and Exam.
|
||||
|
||||
* count: The number of assignments of the type.
|
||||
|
||||
* dropped: Number of assignments of the type that are dropped.
|
||||
|
||||
* weight: The weight, or effect, of the assignment type on the learner's
|
||||
final grade.
|
||||
"""
|
||||
|
||||
allow_empty = False
|
||||
|
||||
@CourseViewMixin.course_check
|
||||
def get(self, request, **kwargs):
|
||||
return Response(api.course_grading_policy(self.course_key))
|
||||
@@ -2224,9 +2224,6 @@ INSTALLED_APPS = [
|
||||
# Coursegraph
|
||||
'openedx.core.djangoapps.coursegraph.apps.CoursegraphConfig',
|
||||
|
||||
# Old course structure API
|
||||
'course_structure_api',
|
||||
|
||||
# Mailchimp Syncing
|
||||
'mailing',
|
||||
|
||||
|
||||
@@ -95,9 +95,6 @@ urlpatterns = [
|
||||
# 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')),
|
||||
|
||||
# Course API
|
||||
url(r'^api/courses/', include('course_api.urls')),
|
||||
|
||||
|
||||
@@ -54,10 +54,6 @@ class EmbargoMiddleware(object):
|
||||
# accidentally lock ourselves out of Django admin
|
||||
# during testing.
|
||||
re.compile(r'^/admin/'),
|
||||
|
||||
# Do not block access to course metadata. This information is needed for
|
||||
# sever-to-server calls.
|
||||
re.compile(r'^/api/course_structure/v[\d+]/courses/{}/$'.format(settings.COURSE_ID_PATTERN)),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -174,34 +174,3 @@ class EmbargoMiddlewareAccessTests(UrlResetMixin, ModuleStoreTestCase):
|
||||
# even though we would have been blocked by country
|
||||
# access rules.
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@patch.dict(settings.FEATURES, {'EMBARGO': True})
|
||||
def test_always_allow_course_detail_access(self):
|
||||
""" Access to the Course Structure API's course detail endpoint should always be granted. """
|
||||
# Make the user staff so that it has permissions to access the views.
|
||||
self.user.is_staff = True
|
||||
self.user.save() # pylint: disable=no-member
|
||||
|
||||
# Blacklist an IP address
|
||||
ip_address = "192.168.10.20"
|
||||
IPFilter.objects.create(
|
||||
blacklist=ip_address,
|
||||
enabled=True
|
||||
)
|
||||
|
||||
url = reverse('course_structure_api:v0:detail', kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(
|
||||
url,
|
||||
HTTP_X_FORWARDED_FOR=ip_address,
|
||||
REMOTE_ADDR=ip_address
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test with a fully-restricted course
|
||||
with restrict_course(self.course.id):
|
||||
response = self.client.get(
|
||||
url,
|
||||
HTTP_X_FORWARDED_FOR=ip_address,
|
||||
REMOTE_ADDR=ip_address
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
Reference in New Issue
Block a user