Merge pull request #17983 from edx/arch/remove-course-structure-api

Remove Course Structure API
This commit is contained in:
Nimisha Asthagiri
2018-04-21 09:04:39 -04:00
committed by GitHub
11 changed files with 0 additions and 860 deletions

View File

@@ -1 +0,0 @@
""" Course structure API """

View File

@@ -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'))
]

View File

@@ -1 +0,0 @@
""" Version 0 """

View File

@@ -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)

View File

@@ -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)

View File

@@ -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'
),
]

View File

@@ -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))

View File

@@ -2224,9 +2224,6 @@ INSTALLED_APPS = [
# Coursegraph
'openedx.core.djangoapps.coursegraph.apps.CoursegraphConfig',
# Old course structure API
'course_structure_api',
# Mailchimp Syncing
'mailing',

View File

@@ -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')),

View File

@@ -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):

View File

@@ -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)