Course Validation and Course Quality APIs

This commit is contained in:
Nimisha Asthagiri
2018-06-05 21:25:21 -04:00
parent 947d82737e
commit 8db2dd47dd
15 changed files with 830 additions and 90 deletions

View File

@@ -0,0 +1,180 @@
"""
APIs related to Course Import.
"""
import base64
import logging
import os
from path import Path as path
from six import text_type
from django.conf import settings
from django.core.files import File
from rest_framework import status
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from user_tasks.models import UserTaskStatus
from contentstore.storage import course_import_export_storage
from contentstore.tasks import CourseImportTask, import_olx
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from .utils import course_author_access_required
log = logging.getLogger(__name__)
@view_auth_classes()
class CourseImportExportViewMixin(DeveloperErrorViewMixin):
"""
Mixin class for course import/export related views.
"""
def perform_authentication(self, request):
"""
Ensures that the user is authenticated (e.g. not an AnonymousUser)
"""
super(CourseImportExportViewMixin, self).perform_authentication(request)
if request.user.is_anonymous:
raise AuthenticationFailed
class CourseImportView(CourseImportExportViewMixin, GenericAPIView):
"""
**Use Case**
* Start an asynchronous task to import a course from a .tar.gz file into
the specified course ID, overwriting the existing course
* Get a status on an asynchronous task import
**Example Requests**
POST /api/courses/v0/import/{course_id}/
GET /api/courses/v0/import/{course_id}/?task_id={task_id}
**POST Parameters**
A POST request must include the following parameters.
* course_id: (required) A string representation of a Course ID,
e.g., course-v1:edX+DemoX+Demo_Course
* course_data: (required) The course .tar.gz file to import
**POST Response Values**
If the import task is started successfully, an HTTP 200 "OK" response is
returned.
The HTTP 200 response has the following values.
* task_id: UUID of the created task, usable for checking status
* filename: string of the uploaded filename
**Example POST Response**
{
"task_id": "4b357bb3-2a1e-441d-9f6c-2210cf76606f"
}
**GET Parameters**
A GET request must include the following parameters.
* task_id: (required) The UUID of the task to check, e.g. "4b357bb3-2a1e-441d-9f6c-2210cf76606f"
* filename: (required) The filename of the uploaded course .tar.gz
**GET Response Values**
If the import task is found successfully by the UUID provided, an HTTP
200 "OK" response is returned.
The HTTP 200 response has the following values.
* state: String description of the state of the task
**Example GET Response**
{
"state": "Succeeded"
}
"""
# TODO: ARCH-91
# This view is excluded from Swagger doc generation because it
# does not specify a serializer class.
exclude_from_schema = True
@course_author_access_required
def post(self, request, course_key):
"""
Kicks off an asynchronous course import and returns an ID to be used to check
the task's status
"""
try:
if 'course_data' not in request.FILES:
raise self.api_error(
status_code=status.HTTP_400_BAD_REQUEST,
developer_message='Missing required parameter',
error_code='internal_error',
)
filename = request.FILES['course_data'].name
if not filename.endswith('.tar.gz'):
raise self.api_error(
status_code=status.HTTP_400_BAD_REQUEST,
developer_message='Parameter in the wrong format',
error_code='internal_error',
)
course_dir = path(settings.GITHUB_REPO_ROOT) / base64.urlsafe_b64encode(repr(course_key))
temp_filepath = course_dir / filename
if not course_dir.isdir():
os.mkdir(course_dir)
log.debug('importing course to {0}'.format(temp_filepath))
with open(temp_filepath, "wb+") as temp_file:
for chunk in request.FILES['course_data'].chunks():
temp_file.write(chunk)
log.info("Course import %s: Upload complete", course_key)
with open(temp_filepath, 'rb') as local_file:
django_file = File(local_file)
storage_path = course_import_export_storage.save(u'olx_import/' + filename, django_file)
async_result = import_olx.delay(
request.user.id, text_type(course_key), storage_path, filename, request.LANGUAGE_CODE)
return Response({
'task_id': async_result.task_id
})
except Exception as e:
log.exception(str(e))
raise self.api_error(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
developer_message=str(e),
error_code='internal_error'
)
@course_author_access_required
def get(self, request, course_key):
"""
Check the status of the specified task
"""
try:
task_id = request.GET['task_id']
filename = request.GET['filename']
args = {u'course_key_string': str(course_key), u'archive_name': filename}
name = CourseImportTask.generate_name(args)
task_status = UserTaskStatus.objects.filter(name=name, task_id=task_id).first()
return Response({
'state': task_status.state
})
except Exception as e:
log.exception(str(e))
raise self.api_error(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
developer_message=str(e),
error_code='internal_error'
)

View File

@@ -0,0 +1,252 @@
# pylint: disable=missing-docstring
import logging
import numpy as np
from scipy import stats
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from edxval.api import get_videos_for_course
from openedx.core.djangoapps.request_cache.middleware import request_cached
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from openedx.core.lib.graph_traversals import traverse_pre_order
from xmodule.modulestore.django import modulestore
from .utils import get_bool_param, course_author_access_required
log = logging.getLogger(__name__)
@view_auth_classes()
class CourseQualityView(DeveloperErrorViewMixin, GenericAPIView):
"""
**Use Case**
**Example Requests**
GET /api/courses/v1/quality/{course_id}/
**GET Parameters**
A GET request may include the following parameters.
* all
* sections
* subsections
* units
* videos
* exclude_graded (boolean) - whether to exclude graded subsections in the subsections and units information.
**GET Response Values**
The HTTP 200 response has the following values.
* is_self_paced - whether the course is self-paced.
* sections
* total_number - number of sections in the course.
* total_visible - number of sections visible to learners in the course.
* number_with_highlights - number of sections that have at least one highlight entered.
* highlights_enabled - whether highlights are enabled in the course.
* subsections
* total_visible - number of subsections visible to learners in the course.
* num_with_one_block_type - number of visible subsections containing only one type of block.
* num_block_types - statistics for number of block types across all visible subsections.
* min
* max
* mean
* median
* mode
* units
* total_visible - number of units visible to learners in the course.
* num_blocks - statistics for number of block across all visible units.
* min
* max
* mean
* median
* mode
* videos
* total_number - number of video blocks in the course.
* num_with_val_id - number of video blocks that include video pipeline IDs.
* num_mobile_encoded - number of videos encoded through the video pipeline.
* durations - statistics for video duration across all videos encoded through the video pipeline.
* min
* max
* mean
* median
* mode
"""
@course_author_access_required
def get(self, request, course_key):
"""
Returns validation information for the given course.
"""
all_requested = get_bool_param(request, 'all', False)
store = modulestore()
with store.bulk_operations(course_key):
course = store.get_course(course_key, depth=self._required_course_depth(request, all_requested))
response = dict(
is_self_paced=course.self_paced,
)
if get_bool_param(request, 'sections', all_requested):
response.update(
sections=self._sections_quality(course)
)
if get_bool_param(request, 'subsections', all_requested):
response.update(
subsections=self._subsections_quality(course, request)
)
if get_bool_param(request, 'units', all_requested):
response.update(
units=self._units_quality(course, request)
)
if get_bool_param(request, 'videos', all_requested):
response.update(
videos=self._videos_quality(course)
)
return Response(response)
def _required_course_depth(self, request, all_requested):
if get_bool_param(request, 'units', all_requested):
# The num_blocks metric for "units" requires retrieving all blocks in the graph.
return None
elif get_bool_param(request, 'subsections', all_requested):
# The num_block_types metric for "subsections" requires retrieving all blocks in the graph.
return None
elif get_bool_param(request, 'sections', all_requested):
return 1
else:
return 0
def _sections_quality(self, course):
sections, visible_sections = self._get_sections(course)
sections_with_highlights = [s for s in visible_sections if s.highlights]
return dict(
total_number=len(sections),
total_visible=len(visible_sections),
number_with_highlights=len(sections_with_highlights),
highlights_enabled=course.highlights_enabled_for_messaging,
)
def _subsections_quality(self, course, request):
subsection_unit_dict = self._get_subsections_and_units(course, request)
num_block_types_per_subsection_dict = {}
for subsection_key, unit_dict in subsection_unit_dict.iteritems():
leaf_block_types_in_subsection = (
unit_info['leaf_block_types']
for unit_info in unit_dict.itervalues()
)
num_block_types_per_subsection_dict[subsection_key] = len(set().union(*leaf_block_types_in_subsection))
return dict(
total_visible=len(num_block_types_per_subsection_dict),
num_with_one_block_type=list(num_block_types_per_subsection_dict.itervalues()).count(1),
num_block_types=self._stats_dict(list(num_block_types_per_subsection_dict.itervalues())),
)
def _units_quality(self, course, request):
subsection_unit_dict = self._get_subsections_and_units(course, request)
num_leaf_blocks_per_unit = [
unit_info['num_leaf_blocks']
for unit_dict in subsection_unit_dict.itervalues()
for unit_info in unit_dict.itervalues()
]
return dict(
total_visible=len(num_leaf_blocks_per_unit),
num_blocks=self._stats_dict(num_leaf_blocks_per_unit),
)
def _videos_quality(self, course):
video_blocks_in_course = modulestore().get_items(course.id, qualifiers={'category': 'video'})
videos_in_val = list(get_videos_for_course(course.id))
video_durations = [video['duration'] for video in videos_in_val]
return dict(
total_number=len(video_blocks_in_course),
num_mobile_encoded=len(videos_in_val),
num_with_val_id=len([v for v in video_blocks_in_course if v.edx_video_id]),
durations=self._stats_dict(video_durations),
)
@request_cached
def _get_subsections_and_units(self, course, request):
"""
Returns {subsection_key: {unit_key: {num_leaf_blocks: <>, leaf_block_types: set(<>) }}}
for all visible subsections and units.
"""
_, visible_sections = self._get_sections(course)
subsection_dict = {}
for section in visible_sections:
visible_subsections = self._get_visible_children(section)
if get_bool_param(request, 'exclude_graded', False):
visible_subsections = [s for s in visible_subsections if not s.graded]
for subsection in visible_subsections:
unit_dict = {}
visible_units = self._get_visible_children(subsection)
for unit in visible_units:
leaf_blocks = self._get_leaf_blocks(unit)
unit_dict[unit.location] = dict(
num_leaf_blocks=len(leaf_blocks),
leaf_block_types=set(block.location.block_type for block in leaf_blocks),
)
subsection_dict[subsection.location] = unit_dict
return subsection_dict
@request_cached
def _get_sections(self, course):
return self._get_all_children(course)
def _get_all_children(self, parent):
store = modulestore()
children = [store.get_item(child_usage_key) for child_usage_key in self._get_children(parent)]
visible_children = [
c for c in children
if not c.visible_to_staff_only and not c.hide_from_toc
]
return children, visible_children
def _get_visible_children(self, parent):
_, visible_chidren = self._get_all_children(parent)
return visible_chidren
def _get_children(self, parent):
if not hasattr(parent, 'children'):
return []
else:
return parent.children
def _get_leaf_blocks(self, unit):
def leaf_filter(block):
return (
block.location.block_type not in ('chapter', 'sequential', 'vertical') and
len(self._get_children(block)) == 0
)
return [
block for block in
traverse_pre_order(unit, self._get_visible_children, leaf_filter)
]
def _stats_dict(self, data):
if not data:
return dict(
min=None,
max=None,
mean=None,
median=None,
mode=None,
)
else:
return dict(
min=min(data),
max=max(data),
mean=np.around(np.mean(data)),
median=np.around(np.median(data)),
mode=stats.mode(data, axis=None)[0][0],
)

View File

@@ -0,0 +1,178 @@
# pylint: disable=missing-docstring
import logging
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from contentstore.course_info_model import get_course_updates
from contentstore.views.certificates import CertificateManager
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from xmodule.modulestore.django import modulestore
from .utils import get_bool_param, course_author_access_required
log = logging.getLogger(__name__)
@view_auth_classes()
class CourseValidationView(DeveloperErrorViewMixin, GenericAPIView):
"""
**Use Case**
**Example Requests**
GET /api/courses/v1/validation/{course_id}/
**GET Parameters**
A GET request may include the following parameters.
* all
* dates
* assignments
* grades
* certificates
* updates
**GET Response Values**
The HTTP 200 response has the following values.
* is_self_paced - whether the course is self-paced.
* dates
* has_start_date - whether the start date is set on the course.
* has_end_date - whether the end date is set on the course.
* assignments
* total_number - total number of assignments in the course.
* total_visible - number of assignments visible to learners in the course.
* num_with_dates - number of assignments with due dates.
* num_with_dates_after_start - number of assignments with due dates after the start date.
* num_with_dates_before_end - number of assignments with due dates before the end date.
* grades
* sum_of_weights - sum of weights for all assignments in the course (valid ones should equal 1).
* certificates
* is_activated - whether the certificate is activated for the course.
* has_certificate - whether the course has a certificate.
* updates
* has_update - whether at least one course update exists.
"""
@course_author_access_required
def get(self, request, course_key):
"""
Returns validation information for the given course.
"""
all_requested = get_bool_param(request, 'all', False)
store = modulestore()
with store.bulk_operations(course_key):
course = store.get_course(course_key, depth=self._required_course_depth(request, all_requested))
response = dict(
is_self_paced=course.self_paced,
)
if get_bool_param(request, 'dates', all_requested):
response.update(
dates=self._dates_validation(course)
)
if get_bool_param(request, 'assignments', all_requested):
response.update(
assignments=self._assignments_validation(course)
)
if get_bool_param(request, 'grades', all_requested):
response.update(
grades=self._grades_validation(course)
)
if get_bool_param(request, 'certificates', all_requested):
response.update(
certificates=self._certificates_validation(course)
)
if get_bool_param(request, 'updates', all_requested):
response.update(
updates=self._updates_validation(course, request)
)
return Response(response)
def _required_course_depth(self, request, all_requested):
if get_bool_param(request, 'assignments', all_requested):
return 2
else:
return 0
def _dates_validation(self, course):
return dict(
has_start_date=self._has_start_date(course),
has_end_date=course.end is not None,
)
def _assignments_validation(self, course):
assignments, visible_assignments = self._get_assignments(course)
assignments_with_dates = [a for a in visible_assignments if a.due]
num_with_dates = len(assignments_with_dates)
num_with_dates_after_start = (
len([a for a in assignments_with_dates if a.due > course.start])
if self._has_start_date(course)
else 0
)
num_with_dates_before_end = (
len([a for a in assignments_with_dates if a.due < course.end])
if course.end
else 0
)
return dict(
total_number=len(assignments),
total_visible=len(visible_assignments),
num_with_dates=num_with_dates,
num_with_dates_after_start=num_with_dates_after_start,
num_with_dates_before_end=num_with_dates_before_end,
)
def _grades_validation(self, course):
sum_of_weights = course.grader.sum_of_weights
return dict(
sum_of_weights=sum_of_weights,
)
def _certificates_validation(self, course):
is_activated, certificates = CertificateManager.is_activated(course)
return dict(
is_activated=is_activated,
has_certificate=len(certificates) > 0,
)
def _updates_validation(self, course, request):
updates_usage_key = course.id.make_usage_key('course_info', 'updates')
updates = get_course_updates(updates_usage_key, provided_id=None, user_id=request.user.id)
return dict(
has_update=len(updates) > 0,
)
def _get_assignments(self, course):
store = modulestore()
sections = [store.get_item(section_usage_key) for section_usage_key in course.children]
assignments = [
store.get_item(assignment_usage_key)
for section in sections
for assignment_usage_key in section.children
]
visible_sections = [
s for s in sections
if not s.visible_to_staff_only and not s.hide_from_toc
]
assignments_in_visible_sections = [
store.get_item(assignment_usage_key)
for visible_section in visible_sections
for assignment_usage_key in visible_section.children
]
visible_assignments = [
a for a in assignments_in_visible_sections
if not a.visible_to_staff_only
]
return assignments, visible_assignments
def _has_start_date(self, course):
return not course.start_date_is_still_default

View File

@@ -0,0 +1,47 @@
"""
Common utilities for Contentstore APIs.
"""
from rest_framework import status
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.util.forms import to_bool
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from student.auth import has_course_author_access
def get_bool_param(request, param_name, default):
param_value = request.query_params.get(param_name, None)
bool_value = to_bool(param_value)
if bool_value is None:
return default
else:
return bool_value
def course_author_access_required(view):
"""
Ensure the user making the API request has course author access to the given course.
This decorator parses the course_id parameter, checks course access, and passes
the parsed course_key to the view as a parameter. It will raise a
403 error if the user does not have author access.
Usage::
@course_author_access_required
def my_view(request, course_key):
# Some functionality ...
"""
def _wrapper_view(self, request, course_id, *args, **kwargs):
"""
Checks for course author access for the given course by the requesting user.
Calls the view function if has access, otherwise raises a 403.
"""
course_key = CourseKey.from_string(course_id)
if not has_course_author_access(request.user, course_key):
raise DeveloperErrorViewMixin.api_error(
status_code=status.HTTP_403_FORBIDDEN,
developer_message='The requesting user does not have course author permissions.',
error_code='user_permissions',
)
return view(self, request, course_key, *args, **kwargs)
return _wrapper_view