Merge pull request #18325 from edx/hack/course-validation-api
Course Validation and Course Quality APIs
This commit is contained in:
@@ -2,19 +2,15 @@
|
||||
Tests for the course import API views
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from urllib import urlencode
|
||||
|
||||
from django.urls import reverse
|
||||
from path import Path as path
|
||||
from mock import patch
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory
|
||||
from lms.djangoapps.courseware.tests.factories import StaffFactory
|
||||
from student.tests.factories import UserFactory
|
||||
from user_tasks.models import UserTaskStatus
|
||||
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase
|
||||
142
cms/djangoapps/contentstore/api/tests/test_quality.py
Normal file
142
cms/djangoapps/contentstore/api/tests/test_quality.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Tests for the course import API views
|
||||
"""
|
||||
from django.core.urlresolvers import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from lms.djangoapps.courseware.tests.factories import StaffFactory
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
|
||||
class CourseQualityViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
"""
|
||||
Test importing courses via a RESTful API (POST method only)
|
||||
"""
|
||||
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CourseQualityViewTest, cls).setUpClass()
|
||||
|
||||
cls.course = CourseFactory.create(display_name='test course', run="Testing_course")
|
||||
cls.course_key = cls.course.id
|
||||
|
||||
cls.password = 'test'
|
||||
cls.student = UserFactory(username='dummy', password=cls.password)
|
||||
cls.staff = StaffFactory(course_key=cls.course.id, password=cls.password)
|
||||
|
||||
cls.initialize_course(cls.course)
|
||||
|
||||
@classmethod
|
||||
def initialize_course(cls, course):
|
||||
course.self_paced = True
|
||||
cls.store.update_item(course, cls.staff.id)
|
||||
|
||||
section = ItemFactory.create(
|
||||
parent_location=course.location,
|
||||
category="chapter",
|
||||
)
|
||||
subsection1 = ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
category="sequential",
|
||||
)
|
||||
unit1 = ItemFactory.create(
|
||||
parent_location=subsection1.location,
|
||||
category="vertical",
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent_location=unit1.location,
|
||||
category="video",
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent_location=unit1.location,
|
||||
category="problem",
|
||||
)
|
||||
|
||||
subsection2 = ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
category="sequential",
|
||||
)
|
||||
unit2 = ItemFactory.create(
|
||||
parent_location=subsection2.location,
|
||||
category="vertical",
|
||||
)
|
||||
unit3 = ItemFactory.create(
|
||||
parent_location=subsection2.location,
|
||||
category="vertical",
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent_location=unit3.location,
|
||||
category="video",
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent_location=unit3.location,
|
||||
category="video",
|
||||
)
|
||||
|
||||
def get_url(self, course_id):
|
||||
"""
|
||||
Helper function to create the url
|
||||
"""
|
||||
return reverse(
|
||||
'courses_api:course_quality',
|
||||
kwargs={
|
||||
'course_id': course_id
|
||||
}
|
||||
)
|
||||
|
||||
def test_student_fails(self):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
resp = self.client.get(self.get_url(self.course_key))
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_staff_succeeds(self):
|
||||
self.client.login(username=self.staff.username, password=self.password)
|
||||
resp = self.client.get(self.get_url(self.course_key), {'all': 'true'})
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
expected_data = {
|
||||
'units': {
|
||||
'num_blocks': {
|
||||
'max': 2,
|
||||
'mean': 1.0,
|
||||
'median': 2.0,
|
||||
'mode': 2.0,
|
||||
'min': 0,
|
||||
},
|
||||
'total_visible': 3,
|
||||
},
|
||||
'videos': {
|
||||
'durations': {
|
||||
'max': None,
|
||||
'mean': None,
|
||||
'median': None,
|
||||
'mode': None,
|
||||
'min': None,
|
||||
},
|
||||
'num_mobile_encoded': 0,
|
||||
'num_with_val_id': 0,
|
||||
'total_number': 3,
|
||||
},
|
||||
'sections': {
|
||||
'number_with_highlights': 0,
|
||||
'total_visible': 1,
|
||||
'total_number': 1,
|
||||
'highlights_enabled': False,
|
||||
},
|
||||
'subsections': {
|
||||
'num_with_one_block_type': 1,
|
||||
'num_block_types': {
|
||||
'max': 2,
|
||||
'mean': 2.0,
|
||||
'median': 2.0,
|
||||
'mode': 1.0,
|
||||
'min': 1,
|
||||
},
|
||||
'total_visible': 2,
|
||||
},
|
||||
'is_self_paced': True,
|
||||
}
|
||||
self.assertDictEqual(resp.data, expected_data)
|
||||
102
cms/djangoapps/contentstore/api/tests/test_validation.py
Normal file
102
cms/djangoapps/contentstore/api/tests/test_validation.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Tests for the course import API views
|
||||
"""
|
||||
from datetime import datetime
|
||||
from django.core.urlresolvers import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from lms.djangoapps.courseware.tests.factories import StaffFactory
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
|
||||
class CourseValidationViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
"""
|
||||
Test importing courses via a RESTful API (POST method only)
|
||||
"""
|
||||
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CourseValidationViewTest, cls).setUpClass()
|
||||
|
||||
cls.course = CourseFactory.create(display_name='test course', run="Testing_course")
|
||||
cls.course_key = cls.course.id
|
||||
|
||||
cls.password = 'test'
|
||||
cls.student = UserFactory(username='dummy', password=cls.password)
|
||||
cls.staff = StaffFactory(course_key=cls.course.id, password=cls.password)
|
||||
|
||||
cls.initialize_course(cls.course)
|
||||
|
||||
@classmethod
|
||||
def initialize_course(cls, course):
|
||||
course.start = datetime.now()
|
||||
course.self_paced = True
|
||||
cls.store.update_item(course, cls.staff.id)
|
||||
|
||||
update_key = course.id.make_usage_key('course_info', 'updates')
|
||||
cls.store.create_item(
|
||||
cls.staff.id,
|
||||
update_key.course_key,
|
||||
update_key.block_type,
|
||||
block_id=update_key.block_id,
|
||||
fields=dict(data=u"<ol><li><h2>Date</h2>Hello world!</li></ol>"),
|
||||
)
|
||||
|
||||
section = ItemFactory.create(
|
||||
parent_location=course.location,
|
||||
category="chapter",
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
category="sequential",
|
||||
)
|
||||
|
||||
def get_url(self, course_id):
|
||||
"""
|
||||
Helper function to create the url
|
||||
"""
|
||||
return reverse(
|
||||
'courses_api:course_validation',
|
||||
kwargs={
|
||||
'course_id': course_id,
|
||||
}
|
||||
)
|
||||
|
||||
def test_student_fails(self):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
resp = self.client.get(self.get_url(self.course_key))
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_staff_succeeds(self):
|
||||
self.client.login(username=self.staff.username, password=self.password)
|
||||
resp = self.client.get(self.get_url(self.course_key), {'all': 'true'})
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
expected_data = {
|
||||
'assignments': {
|
||||
'num_with_dates_before_end': 0,
|
||||
'num_with_dates': 0,
|
||||
'total_visible': 1,
|
||||
'num_with_dates_after_start': 0,
|
||||
'total_number': 1,
|
||||
},
|
||||
'dates': {
|
||||
'has_start_date': True,
|
||||
'has_end_date': False,
|
||||
},
|
||||
'updates': {
|
||||
'has_update': True,
|
||||
},
|
||||
'certificates': {
|
||||
'is_activated': False,
|
||||
'has_certificate': False,
|
||||
},
|
||||
'grades': {
|
||||
'sum_of_weights': 1.0,
|
||||
},
|
||||
'is_self_paced': True,
|
||||
}
|
||||
self.assertDictEqual(resp.data, expected_data)
|
||||
@@ -1,10 +1,14 @@
|
||||
""" Course Import API URLs. """
|
||||
""" Course API URLs. """
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from cms.djangoapps.contentstore.api import views
|
||||
from cms.djangoapps.contentstore.api.views import course_import, course_validation, course_quality
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^v0/import/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN,),
|
||||
views.CourseImportView.as_view(), name='course_import'),
|
||||
course_import.CourseImportView.as_view(), name='course_import'),
|
||||
url(r'^v1/validation/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN,),
|
||||
course_validation.CourseValidationView.as_view(), name='course_validation'),
|
||||
url(r'^v1/quality/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN,),
|
||||
course_quality.CourseQualityView.as_view(), name='course_quality'),
|
||||
]
|
||||
|
||||
0
cms/djangoapps/contentstore/api/views/__init__.py
Normal file
0
cms/djangoapps/contentstore/api/views/__init__.py
Normal file
@@ -1,4 +1,6 @@
|
||||
""" API v0 views. """
|
||||
"""
|
||||
APIs related to Course Import.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
@@ -9,19 +11,19 @@ from six import text_type
|
||||
from django.conf import settings
|
||||
|
||||
from django.core.files import File
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
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 student.auth import has_course_author_access
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
@@ -106,39 +108,30 @@ class CourseImportView(CourseImportExportViewMixin, GenericAPIView):
|
||||
# does not specify a serializer class.
|
||||
exclude_from_schema = True
|
||||
|
||||
def post(self, request, course_id):
|
||||
@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
|
||||
"""
|
||||
|
||||
courselike_key = CourseKey.from_string(course_id)
|
||||
if not has_course_author_access(request.user, courselike_key):
|
||||
return self.make_error_response(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
developer_message='The user requested does not have the required permissions.',
|
||||
error_code='user_mismatch'
|
||||
)
|
||||
try:
|
||||
if 'course_data' not in request.FILES:
|
||||
return self.make_error_response(
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
developer_message='Missing required parameter',
|
||||
error_code='internal_error',
|
||||
field_errors={'course_data': '"course_data" parameter is required, and must be a .tar.gz file'}
|
||||
)
|
||||
|
||||
filename = request.FILES['course_data'].name
|
||||
if not filename.endswith('.tar.gz'):
|
||||
return self.make_error_response(
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
developer_message='Parameter in the wrong format',
|
||||
error_code='internal_error',
|
||||
field_errors={'course_data': '"course_data" parameter is required, and must be a .tar.gz file'}
|
||||
)
|
||||
course_dir = path(settings.GITHUB_REPO_ROOT) / base64.urlsafe_b64encode(repr(courselike_key))
|
||||
course_dir = path(settings.GITHUB_REPO_ROOT) / base64.urlsafe_b64encode(repr(course_key))
|
||||
temp_filepath = course_dir / filename
|
||||
if not course_dir.isdir(): # pylint: disable=no-value-for-parameter
|
||||
if not course_dir.isdir():
|
||||
os.mkdir(course_dir)
|
||||
|
||||
log.debug('importing course to {0}'.format(temp_filepath))
|
||||
@@ -146,46 +139,41 @@ class CourseImportView(CourseImportExportViewMixin, GenericAPIView):
|
||||
for chunk in request.FILES['course_data'].chunks():
|
||||
temp_file.write(chunk)
|
||||
|
||||
log.info("Course import %s: Upload complete", courselike_key)
|
||||
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(courselike_key), storage_path, filename, request.LANGUAGE_CODE)
|
||||
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:
|
||||
return self.make_error_response(
|
||||
log.exception(str(e))
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
developer_message=str(e),
|
||||
error_code='internal_error'
|
||||
)
|
||||
|
||||
def get(self, request, course_id):
|
||||
@course_author_access_required
|
||||
def get(self, request, course_key):
|
||||
"""
|
||||
Check the status of the specified task
|
||||
"""
|
||||
|
||||
courselike_key = CourseKey.from_string(course_id)
|
||||
if not has_course_author_access(request.user, courselike_key):
|
||||
return self.make_error_response(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
developer_message='The user requested does not have the required permissions.',
|
||||
error_code='user_mismatch'
|
||||
)
|
||||
try:
|
||||
task_id = request.GET['task_id']
|
||||
filename = request.GET['filename']
|
||||
args = {u'course_key_string': course_id, u'archive_name': 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:
|
||||
return self.make_error_response(
|
||||
log.exception(str(e))
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
developer_message=str(e),
|
||||
error_code='internal_error'
|
||||
252
cms/djangoapps/contentstore/api/views/course_quality.py
Normal file
252
cms/djangoapps/contentstore/api/views/course_quality.py
Normal 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],
|
||||
)
|
||||
178
cms/djangoapps/contentstore/api/views/course_validation.py
Normal file
178
cms/djangoapps/contentstore/api/views/course_validation.py
Normal 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
|
||||
47
cms/djangoapps/contentstore/api/views/utils.py
Normal file
47
cms/djangoapps/contentstore/api/views/utils.py
Normal 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
|
||||
@@ -157,6 +157,22 @@ class CertificateManager(object):
|
||||
if not certificate_data.get("name"):
|
||||
raise CertificateValidationError(_("must have name of the certificate"))
|
||||
|
||||
@staticmethod
|
||||
def is_activated(course):
|
||||
"""
|
||||
Returns whether certificates are activated for the given course,
|
||||
along with the certificates.
|
||||
"""
|
||||
is_active = False
|
||||
certificates = None
|
||||
if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
|
||||
certificates = CertificateManager.get_certificates(course)
|
||||
# we are assuming only one certificate in certificates collection.
|
||||
for certificate in certificates:
|
||||
is_active = certificate.get('is_active', False)
|
||||
break
|
||||
return is_active, certificates
|
||||
|
||||
@staticmethod
|
||||
def get_used_ids(course):
|
||||
"""
|
||||
@@ -392,14 +408,8 @@ def certificates_list_handler(request, course_key_string):
|
||||
)
|
||||
else:
|
||||
certificate_web_view_url = None
|
||||
certificates = None
|
||||
is_active = False
|
||||
if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
|
||||
certificates = CertificateManager.get_certificates(course)
|
||||
# we are assuming only one certificate in certificates collection.
|
||||
for certificate in certificates:
|
||||
is_active = certificate.get('is_active', False)
|
||||
break
|
||||
|
||||
is_active, certificates = CertificateManager.is_activated(course)
|
||||
|
||||
return render_to_response('certificates.html', {
|
||||
'context_course': course,
|
||||
|
||||
@@ -262,6 +262,13 @@ class WeightedSubsectionsGrader(CourseGrader):
|
||||
def __init__(self, subgraders):
|
||||
self.subgraders = subgraders
|
||||
|
||||
@property
|
||||
def sum_of_weights(self):
|
||||
sum = 0
|
||||
for _, _, weight in self.subgraders:
|
||||
sum += weight
|
||||
return sum
|
||||
|
||||
def grade(self, grade_sheet, generate_random_scores=False):
|
||||
total_percent = 0.0
|
||||
section_breakdown = []
|
||||
|
||||
@@ -168,14 +168,14 @@ class CourseGradesView(GradeViewMixin, GenericAPIView):
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
except InvalidKeyError:
|
||||
return self.make_error_response(
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The provided course key cannot be parsed.',
|
||||
error_code='invalid_course_key'
|
||||
)
|
||||
|
||||
if not CourseOverview.get_from_id_if_exists(course_key):
|
||||
return self.make_error_response(
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message="Requested grade for unknown course {course}".format(course=course_id),
|
||||
error_code='course_does_not_exist'
|
||||
@@ -186,13 +186,13 @@ class CourseGradesView(GradeViewMixin, GenericAPIView):
|
||||
try:
|
||||
return self._get_single_user_grade(request, course_key)
|
||||
except USER_MODEL.DoesNotExist:
|
||||
return self.make_error_response(
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The user matching the requested username does not exist.',
|
||||
error_code='user_does_not_exist'
|
||||
)
|
||||
except CourseEnrollment.DoesNotExist:
|
||||
return self.make_error_response(
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The user matching the requested username is not enrolled in this course',
|
||||
error_code='user_not_enrolled'
|
||||
|
||||
@@ -35,7 +35,7 @@ class GradeViewMixin(DeveloperErrorViewMixin):
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
except InvalidKeyError:
|
||||
return self.make_error_response(
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The provided course key cannot be parsed.',
|
||||
error_code='invalid_course_key'
|
||||
@@ -52,7 +52,8 @@ class GradeViewMixin(DeveloperErrorViewMixin):
|
||||
log.info('Course with ID "%s" not found', course_key_string)
|
||||
except CourseAccessRedirect:
|
||||
log.info('User %s does not have access to course with ID "%s"', user.username, course_key_string)
|
||||
return self.make_error_response(
|
||||
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The user, the course or both do not exist.',
|
||||
error_code='user_or_course_does_not_exist',
|
||||
@@ -84,7 +85,7 @@ class GradeViewMixin(DeveloperErrorViewMixin):
|
||||
request.user.username,
|
||||
username
|
||||
)
|
||||
return self.make_error_response(
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
developer_message='The user requested does not match the logged in user.',
|
||||
error_code='user_mismatch'
|
||||
@@ -94,7 +95,7 @@ class GradeViewMixin(DeveloperErrorViewMixin):
|
||||
return USER_MODEL.objects.get(username=username)
|
||||
|
||||
except USER_MODEL.DoesNotExist:
|
||||
return self.make_error_response(
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The user matching the requested username does not exist.',
|
||||
error_code='user_does_not_exist'
|
||||
@@ -173,17 +174,9 @@ class UserGradeView(GradeViewMixin, GenericAPIView):
|
||||
"""
|
||||
|
||||
course = self._get_course(course_id, request.user, 'load')
|
||||
if isinstance(course, Response):
|
||||
# Returns a 404 if course_id is invalid, or request.user is not enrolled in the course
|
||||
return course
|
||||
|
||||
grade_user = self._get_effective_user(request, course)
|
||||
if isinstance(grade_user, Response):
|
||||
# Returns a 403 if the request.user can't access grades for the requested user,
|
||||
# or a 404 if the requested user does not exist.
|
||||
return grade_user
|
||||
|
||||
course_grade = CourseGradeFactory().read(grade_user, course)
|
||||
|
||||
return Response([{
|
||||
'username': grade_user.username,
|
||||
'course_key': course_id,
|
||||
@@ -221,6 +214,4 @@ class CourseGradingPolicy(GradeViewMixin, ListAPIView):
|
||||
|
||||
def get(self, request, course_id, **kwargs):
|
||||
course = self._get_course(course_id, request.user, 'staff')
|
||||
if isinstance(course, Response):
|
||||
return course
|
||||
return Response(GradingPolicySerializer(course.raw_grader, many=True).data)
|
||||
|
||||
@@ -65,17 +65,21 @@ class ExtendedNullBooleanField(NullBooleanField):
|
||||
widget = Select(choices=NULL_BOOLEAN_CHOICES)
|
||||
|
||||
def to_python(self, value):
|
||||
"""
|
||||
Explicitly checks for the string 'True', 'False', 'true',
|
||||
'false', '1' and '0' and returns boolean True or False.
|
||||
Returns None if value is not passed at all and raises an
|
||||
exception for any other value.
|
||||
"""
|
||||
if value in (True, 'True', 'true', '1'):
|
||||
return True
|
||||
elif value in (False, 'False', 'false', '0'):
|
||||
return False
|
||||
elif not value:
|
||||
return None
|
||||
else:
|
||||
raise ValidationError("Invalid Boolean Value.")
|
||||
return to_bool(value)
|
||||
|
||||
|
||||
def to_bool(value):
|
||||
"""
|
||||
Explicitly checks for the string 'True', 'False', 'true',
|
||||
'false', '1' and '0' and returns boolean True or False.
|
||||
Returns None if value is not passed at all and raises an
|
||||
exception for any other value.
|
||||
"""
|
||||
if value in (True, 'True', 'true', '1'):
|
||||
return True
|
||||
elif value in (False, 'False', 'false', '0'):
|
||||
return False
|
||||
elif not value:
|
||||
return None
|
||||
else:
|
||||
raise ValidationError("Invalid Boolean Value.")
|
||||
|
||||
@@ -21,13 +21,30 @@ from openedx.core.lib.api.authentication import (
|
||||
from openedx.core.lib.api.permissions import IsUserInUrl
|
||||
|
||||
|
||||
class DeveloperErrorResponseException(Exception):
|
||||
"""
|
||||
An exception class that wraps a DRF Response object so that
|
||||
it does not need to be recreated when returning a response.
|
||||
Intended to be used with and by DeveloperErrorViewMixin.
|
||||
"""
|
||||
def __init__(self, response):
|
||||
super(DeveloperErrorResponseException, self).__init__()
|
||||
self.response = response
|
||||
|
||||
|
||||
class DeveloperErrorViewMixin(object):
|
||||
"""
|
||||
A view mixin to handle common error cases other than validation failure
|
||||
(auth failure, method not allowed, etc.) by generating an error response
|
||||
conforming to our API conventions with a developer message.
|
||||
"""
|
||||
def make_error_response(self, status_code, developer_message, error_code=None):
|
||||
@classmethod
|
||||
def api_error(cls, status_code, developer_message, error_code=None):
|
||||
response = cls._make_error_response(status_code, developer_message, error_code)
|
||||
return DeveloperErrorResponseException(response)
|
||||
|
||||
@classmethod
|
||||
def _make_error_response(cls, status_code, developer_message, error_code=None):
|
||||
"""
|
||||
Build an error response with the given status code and developer_message
|
||||
"""
|
||||
@@ -36,7 +53,8 @@ class DeveloperErrorViewMixin(object):
|
||||
error_data['error_code'] = error_code
|
||||
return Response(error_data, status=status_code)
|
||||
|
||||
def make_validation_error_response(self, validation_error):
|
||||
@classmethod
|
||||
def _make_validation_error_response(cls, validation_error):
|
||||
"""
|
||||
Build a 400 error response from the given ValidationError
|
||||
"""
|
||||
@@ -57,19 +75,20 @@ class DeveloperErrorViewMixin(object):
|
||||
}
|
||||
return Response(response_obj, status=400)
|
||||
else:
|
||||
return self.make_error_response(400, validation_error.messages[0])
|
||||
return cls._make_error_response(400, validation_error.messages[0])
|
||||
|
||||
def handle_exception(self, exc):
|
||||
"""
|
||||
Generalized helper method for managing specific API exception workflows
|
||||
"""
|
||||
|
||||
if isinstance(exc, APIException):
|
||||
return self.make_error_response(exc.status_code, exc.detail)
|
||||
if isinstance(exc, DeveloperErrorResponseException):
|
||||
return exc.response
|
||||
elif isinstance(exc, APIException):
|
||||
return self._make_error_response(exc.status_code, exc.detail)
|
||||
elif isinstance(exc, Http404) or isinstance(exc, ObjectDoesNotExist):
|
||||
return self.make_error_response(404, text_type(exc) or "Not found.")
|
||||
return self._make_error_response(404, text_type(exc) or "Not found.")
|
||||
elif isinstance(exc, ValidationError):
|
||||
return self.make_validation_error_response(exc)
|
||||
return self._make_validation_error_response(exc)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
Reference in New Issue
Block a user