Merge pull request #18325 from edx/hack/course-validation-api

Course Validation and Course Quality APIs
This commit is contained in:
Nimisha Asthagiri
2018-06-13 21:33:22 -04:00
committed by GitHub
15 changed files with 830 additions and 90 deletions

View File

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

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

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

View File

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

View 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'

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

View File

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

View File

@@ -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 = []

View File

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

View File

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

View File

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

View File

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