diff --git a/cms/djangoapps/contentstore/api/tests/test_views.py b/cms/djangoapps/contentstore/api/tests/test_import.py
similarity index 97%
rename from cms/djangoapps/contentstore/api/tests/test_views.py
rename to cms/djangoapps/contentstore/api/tests/test_import.py
index 710663498d..5c9f3f34b6 100644
--- a/cms/djangoapps/contentstore/api/tests/test_views.py
+++ b/cms/djangoapps/contentstore/api/tests/test_import.py
@@ -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
diff --git a/cms/djangoapps/contentstore/api/tests/test_quality.py b/cms/djangoapps/contentstore/api/tests/test_quality.py
new file mode 100644
index 0000000000..e7dba1c458
--- /dev/null
+++ b/cms/djangoapps/contentstore/api/tests/test_quality.py
@@ -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)
diff --git a/cms/djangoapps/contentstore/api/tests/test_validation.py b/cms/djangoapps/contentstore/api/tests/test_validation.py
new file mode 100644
index 0000000000..acaa4dc0c6
--- /dev/null
+++ b/cms/djangoapps/contentstore/api/tests/test_validation.py
@@ -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"
Date
Hello world!
"),
+ )
+
+ 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)
diff --git a/cms/djangoapps/contentstore/api/urls.py b/cms/djangoapps/contentstore/api/urls.py
index d758aa5ed3..97cec4c1ee 100644
--- a/cms/djangoapps/contentstore/api/urls.py
+++ b/cms/djangoapps/contentstore/api/urls.py
@@ -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'),
]
diff --git a/cms/djangoapps/contentstore/api/views/__init__.py b/cms/djangoapps/contentstore/api/views/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cms/djangoapps/contentstore/api/views.py b/cms/djangoapps/contentstore/api/views/course_import.py
similarity index 75%
rename from cms/djangoapps/contentstore/api/views.py
rename to cms/djangoapps/contentstore/api/views/course_import.py
index aa4f20d087..639b504bfd 100644
--- a/cms/djangoapps/contentstore/api/views.py
+++ b/cms/djangoapps/contentstore/api/views/course_import.py
@@ -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'
diff --git a/cms/djangoapps/contentstore/api/views/course_quality.py b/cms/djangoapps/contentstore/api/views/course_quality.py
new file mode 100644
index 0000000000..e2ecfc841a
--- /dev/null
+++ b/cms/djangoapps/contentstore/api/views/course_quality.py
@@ -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],
+ )
diff --git a/cms/djangoapps/contentstore/api/views/course_validation.py b/cms/djangoapps/contentstore/api/views/course_validation.py
new file mode 100644
index 0000000000..f6c68e9e17
--- /dev/null
+++ b/cms/djangoapps/contentstore/api/views/course_validation.py
@@ -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
diff --git a/cms/djangoapps/contentstore/api/views/utils.py b/cms/djangoapps/contentstore/api/views/utils.py
new file mode 100644
index 0000000000..589ce3de04
--- /dev/null
+++ b/cms/djangoapps/contentstore/api/views/utils.py
@@ -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
diff --git a/cms/djangoapps/contentstore/views/certificates.py b/cms/djangoapps/contentstore/views/certificates.py
index fd2e17d756..d2ed27ae50 100644
--- a/cms/djangoapps/contentstore/views/certificates.py
+++ b/cms/djangoapps/contentstore/views/certificates.py
@@ -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,
diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py
index f1f11b7e1e..f04a4bcd9e 100644
--- a/common/lib/xmodule/xmodule/graders.py
+++ b/common/lib/xmodule/xmodule/graders.py
@@ -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 = []
diff --git a/lms/djangoapps/grades/api/v1/views.py b/lms/djangoapps/grades/api/v1/views.py
index f9696c3bbd..36c63396a2 100644
--- a/lms/djangoapps/grades/api/v1/views.py
+++ b/lms/djangoapps/grades/api/v1/views.py
@@ -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'
diff --git a/lms/djangoapps/grades/api/views.py b/lms/djangoapps/grades/api/views.py
index d16a4b627f..a54fe58ac5 100644
--- a/lms/djangoapps/grades/api/views.py
+++ b/lms/djangoapps/grades/api/views.py
@@ -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)
diff --git a/openedx/core/djangoapps/util/forms.py b/openedx/core/djangoapps/util/forms.py
index 6ec4a92d5c..bb60de9354 100644
--- a/openedx/core/djangoapps/util/forms.py
+++ b/openedx/core/djangoapps/util/forms.py
@@ -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.")
diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py
index ed552ae11c..729d5eb128 100644
--- a/openedx/core/lib/api/view_utils.py
+++ b/openedx/core/lib/api/view_utils.py
@@ -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