Files
edx-platform/lms/djangoapps/course_structure_api/v0/views.py
2015-06-12 09:27:20 -04:00

684 lines
27 KiB
Python

""" API implementation for course-oriented interactions. """
from collections import namedtuple
import json
import logging
from django.conf import settings
from django.http import Http404
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
from rest_framework.exceptions import AuthenticationFailed, ParseError
from rest_framework.generics import RetrieveAPIView, ListAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.reverse import reverse
from xmodule.modulestore.django import modulestore
from opaque_keys.edx.keys import CourseKey
from course_structure_api.v0 import api, serializers
from course_structure_api.v0.errors import CourseNotFoundError, CourseStructureNotAvailableError
from courseware import courses
from courseware.access import has_access
from courseware.model_data import FieldDataCache
from courseware.module_render import get_module_for_descriptor
from openedx.core.lib.api.view_utils import view_course_access, view_auth_classes
from openedx.core.lib.api.serializers import PaginationSerializer
from student.roles import CourseInstructorRole, CourseStaffRole
from util.module_utils import get_dynamic_descriptor_children
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 = (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 (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.
"""
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)
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.
"""
@CourseViewMixin.course_check
def get(self, request, **kwargs):
try:
return Response(api.course_structure(self.course_key))
except 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_auth_classes()
class CourseBlocksAndNavigation(ListAPIView):
"""
**Use Case**
The following endpoints return the content of the course according to the requesting user's access level.
* Blocks - Get the course's blocks.
* Navigation - Get the course's navigation information per the navigation depth requested.
* Blocks+Navigation - Get both the course's blocks and the course's navigation information.
**Example requests**:
GET api/course_structure/v0/courses/{course_id}/blocks/
GET api/course_structure/v0/courses/{course_id}/navigation/
GET api/course_structure/v0/courses/{course_id}/blocks+navigation/
&block_count=video
&block_json={"video":{"profiles":["mobile_low"]}}
&fields=graded,format,responsive_ui
**Parameters**:
* block_json: (dict) Indicates for which block types to return student_view_json data. The key is the block
type and the value is the "context" that is passed to the block's student_view_json method.
Example: block_json={"video":{"profiles":["mobile_high","mobile_low"]}}
* block_count: (list) Indicates for which block types to return the aggregate count of the blocks.
Example: block_count="video,problem"
* fields: (list) Indicates which additional fields to return for each block.
Default is "children,graded,format,responsive_ui"
Example: fields=graded,format,responsive_ui
* navigation_depth (integer) Indicates how far deep to traverse into the course hierarchy before bundling
all the descendants.
Default is 3 since typical navigational views of the course show a maximum of chapter->sequential->vertical.
Example: navigation_depth=3
**Response Values**
The following fields are returned with a successful response.
Only either one of blocks, navigation, or blocks+navigation is returned depending on which endpoint is used.
The "root" field is returned for all endpoints.
* root: The ID of the root node of the course blocks.
* blocks: A dictionary that maps block usage IDs to a collection of information about each block.
Each block contains the following fields. Returned only if using the "blocks" endpoint.
* id: (string) The usage ID of the block.
* type: (string) The type of block. Possible values include course, chapter, 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: (string) The display name of the block.
* children: (list) If the block has child blocks, a list of IDs of the child blocks.
Returned only if the "children" input parameter is True.
* block_count: (dict) For each block type specified in the block_count parameter to the endpoint, the
aggregate number of blocks of that type for this block and all of its descendants.
Returned only if the "block_count" input parameter contains this block's type.
* block_json: (dict) The JSON data for this block.
Returned only if the "block_json" input parameter contains this block's type.
* block_url: (string) The URL to retrieve the HTML rendering of this block. The HTML could include
CSS and Javascript code. This URL can be used as a fallback if the custom block_json for this
block type is not requested and not supported.
* web_url: (string) The URL to the website location of this block. This URL can be used as a further
fallback if the block_url and the block_json is not supported.
* graded (boolean) Whether or not the block or any of its descendants is graded.
Returned only if "graded" is included in the "fields" parameter.
* format: (string) The assignment type of the block.
Possible values can be "Homework", "Lab", "Midterm Exam", and "Final Exam".
Returned only if "format" is included in the "fields" parameter.
* responsive_ui: (boolean) Whether or not the block's rendering obtained via block_url is responsive.
Returned only if "responsive_ui" is included in the "fields" parameter.
* navigation: A dictionary that maps block IDs to a collection of navigation information about each block.
Each block contains the following fields. Returned only if using the "navigation" endpoint.
* descendants: (list) A list of IDs of the children of the block if the block's depth in the
course hierarchy is less than the navigation_depth. Otherwise, a list of IDs of the aggregate descendants
of the block.
* blocks+navigation: A dictionary that combines both the blocks and navigation data.
Returned only if using the "blocks+navigation" endpoint.
"""
class RequestInfo(object):
"""
A class for encapsulating the request information, including what optional fields are requested.
"""
DEFAULT_FIELDS = "children,graded,format,responsive_ui"
def __init__(self, request, course):
self.request = request
self.course = course
self.field_data_cache = None
# check what fields are requested
try:
# fields
self.fields = set(request.GET.get('fields', self.DEFAULT_FIELDS).split(","))
# children
self.children = 'children' in self.fields
self.fields.discard('children')
# block_count
self.block_count = request.GET.get('block_count', "")
self.block_count = (
self.block_count.split(",") if self.block_count else []
)
# navigation_depth
# See docstring for why we default to 3.
self.navigation_depth = int(request.GET.get('navigation_depth', '3'))
# block_json
self.block_json = json.loads(request.GET.get('block_json', "{}"))
if self.block_json and not isinstance(self.block_json, dict):
raise ParseError
except:
raise ParseError
class ResultData(object):
"""
A class for encapsulating the result information, specifically the blocks and navigation data.
"""
def __init__(self, return_blocks, return_nav):
self.blocks = {}
self.navigation = {}
if return_blocks and return_nav:
self.navigation = self.blocks
def update_response(self, response, return_blocks, return_nav):
"""
Updates the response object with result information.
"""
if return_blocks and return_nav:
response["blocks+navigation"] = self.blocks
elif return_blocks:
response["blocks"] = self.blocks
elif return_nav:
response["navigation"] = self.navigation
class BlockInfo(object):
"""
A class for encapsulating a block's information as needed during traversal of a block hierarchy.
"""
def __init__(self, block, request_info, parent_block_info=None):
# the block for which the recursion is being computed
self.block = block
# the type of the block
self.type = block.category
# the block's depth in the block hierarchy
self.depth = 0
# the block's children
self.children = []
# descendants_of_parent: the list of descendants for this block's parent
self.descendants_of_parent = []
self.descendants_of_self = []
# if a parent block was provided, update this block's data based on the parent's data
if parent_block_info:
# increment this block's depth value
self.depth = parent_block_info.depth + 1
# set this blocks' descendants_of_parent
self.descendants_of_parent = parent_block_info.descendants_of_self
# add ourselves to the parent's children, if requested.
if request_info.children:
parent_block_info.value.setdefault("children", []).append(unicode(block.location))
# the block's data to include in the response
self.value = {
"id": unicode(block.location),
"type": self.type,
"display_name": block.display_name,
"web_url": reverse(
"jump_to",
kwargs={"course_id": unicode(request_info.course.id), "location": unicode(block.location)},
request=request_info.request,
),
"block_url": reverse(
"courseware.views.render_xblock",
kwargs={"usage_key_string": unicode(block.location)},
request=request_info.request,
),
}
@view_course_access(depth=None)
def list(self, request, course, return_blocks=True, return_nav=True, *args, **kwargs):
"""
REST API endpoint for listing all the blocks and/or navigation information in the course,
while regarding user access and roles.
Arguments:
request - Django request object
course - course module object
return_blocks - If true, returns the blocks information for the course.
return_nav - If true, returns the navigation information for the course.
"""
# set starting point
start_block = course
# initialize request and result objects
request_info = self.RequestInfo(request, course)
result_data = self.ResultData(return_blocks, return_nav)
# create and populate a field data cache by pre-fetching for the course (with depth=None)
request_info.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course.id, request.user, course, depth=None,
)
# start the recursion with the start_block
self.recurse_blocks_nav(request_info, result_data, self.BlockInfo(start_block, request_info))
# return response
response = {"root": unicode(start_block.location)}
result_data.update_response(response, return_blocks, return_nav)
return Response(response)
def recurse_blocks_nav(self, request_info, result_data, block_info):
"""
A depth-first recursive function that supports calculation of both the list of blocks in the course
and the navigation information up to the requested navigation_depth of the course.
Arguments:
request_info - Object encapsulating the request information.
result_data - Running result data that is updated during the recursion.
block_info - Information about the current block in the recursion.
"""
# bind user data to the block
block_info.block = get_module_for_descriptor(
request_info.request.user,
request_info.request,
block_info.block,
request_info.field_data_cache,
request_info.course.id
)
# verify the user has access to this block
if not has_access(request_info.request.user, 'load', block_info.block, course_key=request_info.course.id):
return
# add the block's value to the result
result_data.blocks[unicode(block_info.block.location)] = block_info.value
# descendants
self.update_descendants(request_info, result_data, block_info)
# children: recursively call the function for each of the children, while supporting dynamic children.
if block_info.block.has_children:
block_info.children = get_dynamic_descriptor_children(block_info.block, request_info.request.user.id)
for child in block_info.children:
self.recurse_blocks_nav(
request_info,
result_data,
self.BlockInfo(child, request_info, parent_block_info=block_info)
)
# block count
self.update_block_count(request_info, result_data, block_info)
# block JSON data
self.add_block_json(request_info, block_info)
# additional fields
self.add_additional_fields(request_info, block_info)
def update_descendants(self, request_info, result_data, block_info):
"""
Updates the descendants data for the current block.
The current block is added to its parent's descendants if it is visible in the navigation
(i.e., the 'hide_from_toc' setting is False).
Additionally, the block's depth is compared with the navigation_depth parameter to determine whether the
descendants of the block should be added to its own descendants (if block.depth <= navigation_depth)
or to the descendants of the block's parents (if block.depth > navigation_depth).
block_info.descendants_of_self is the list of descendants that is passed to this block's children.
It should be either:
descendants_of_parent - if this block's depth is greater than the requested navigation_depth.
a dangling [] - if this block's hide_from_toc is True.
a referenced [] in navigation[block.location]["descendants"] - if this block's depth is within
the requested navigation depth.
"""
# Blocks with the 'hide_from_toc' setting are accessible, just not navigatable from the table-of-contents.
# If the 'hide_from_toc' setting is set on the block, do not add this block to the parent's descendants
# list and let the block's descendants add themselves to a dangling (unreferenced) descendants list.
if not block_info.block.hide_from_toc:
# add this block to the parent's descendants
block_info.descendants_of_parent.append(unicode(block_info.block.location))
# if this block's depth in the hierarchy is greater than the requested navigation depth,
# have the block's descendants add themselves to the parent's descendants.
if block_info.depth > request_info.navigation_depth:
block_info.descendants_of_self = block_info.descendants_of_parent
# otherwise, have the block's descendants add themselves to this block's descendants by
# referencing/attaching descendants_of_self from this block's navigation value.
else:
result_data.navigation.setdefault(
unicode(block_info.block.location), {}
)["descendants"] = block_info.descendants_of_self
def update_block_count(self, request_info, result_data, block_info):
"""
For all the block types that are requested to be counted, include the count of that block type as
aggregated from the block's descendants.
Arguments:
request_info - Object encapsulating the request information.
result_data - Running result data that is updated during the recursion.
block_info - Information about the current block in the recursion.
"""
for b_type in request_info.block_count:
block_info.value.setdefault("block_count", {})[b_type] = (
sum(
result_data.blocks.get(unicode(child.location), {}).get("block_count", {}).get(b_type, 0)
for child in block_info.children
) +
(1 if b_type == block_info.type else 0)
)
def add_block_json(self, request_info, block_info):
"""
If the JSON data for this block's type is requested, and the block supports the 'student_view_json'
method, add the response from the 'student_view_json" method as the data for the block.
"""
if block_info.type in request_info.block_json:
if getattr(block_info.block, 'student_view_json', None):
block_info.value["block_json"] = block_info.block.student_view_json(
context=request_info.block_json[block_info.type]
)
# A mapping of API-exposed field names to xBlock field names and API field defaults.
BlockApiField = namedtuple('BlockApiField', 'block_field_name api_field_default')
FIELD_MAP = {
'graded': BlockApiField(block_field_name='graded', api_field_default=False),
'format': BlockApiField(block_field_name='format', api_field_default=None),
'responsive_ui': BlockApiField(block_field_name='has_responsive_ui', api_field_default=False),
}
def add_additional_fields(self, request_info, block_info):
"""
Add additional field names and values of the block as requested in the request_info.
"""
for field_name in request_info.fields:
if field_name in self.FIELD_MAP:
block_info.value[field_name] = getattr(
block_info.block,
self.FIELD_MAP[field_name].block_field_name,
self.FIELD_MAP[field_name].api_field_default,
)
def perform_authentication(self, request):
"""
Ensures that the user is authenticated (e.g. not an AnonymousUser)
"""
super(CourseBlocksAndNavigation, self).perform_authentication(request)
if request.user.is_anonymous():
raise AuthenticationFailed