Added urls and built endpoint.
This commit is contained in:
0
lms/djangoapps/completion/api/__init__.py
Normal file
0
lms/djangoapps/completion/api/__init__.py
Normal file
8
lms/djangoapps/completion/api/urls.py
Normal file
8
lms/djangoapps/completion/api/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Api URLs.
|
||||
"""
|
||||
from django.conf.urls import include, url
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^v1/', include('lms.djangoapps.completion.api.v1.urls', namespace='v1')),
|
||||
]
|
||||
0
lms/djangoapps/completion/api/v1/__init__.py
Normal file
0
lms/djangoapps/completion/api/v1/__init__.py
Normal file
0
lms/djangoapps/completion/api/v1/tests/__init__.py
Normal file
0
lms/djangoapps/completion/api/v1/tests/__init__.py
Normal file
216
lms/djangoapps/completion/api/v1/tests/test_views.py
Normal file
216
lms/djangoapps/completion/api/v1/tests/test_views.py
Normal file
@@ -0,0 +1,216 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test models, managers, and validators.
|
||||
"""
|
||||
|
||||
import ddt
|
||||
from django.core.urlresolvers import reverse
|
||||
from rest_framework.test import APIClient, force_authenticate
|
||||
|
||||
from completion import waffle
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from openedx.core.djangoapps.content.course_structures.tasks import update_course_structure
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CompletionBatchTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Test that BlockCompletion.objects.submit_batch_completion has the desired
|
||||
semantics.
|
||||
"""
|
||||
ENROLLED_USERNAME = 'test_user'
|
||||
UNENROLLED_USERNAME = 'unenrolled_user'
|
||||
COURSE_KEY = 'TestX/101/Test'
|
||||
BLOCK_KEY = 'i4x://TestX/101/problem/Test_Problem'
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create the test data.
|
||||
"""
|
||||
super(CompletionBatchTestCase, self).setUp()
|
||||
self.url = reverse('completion_api:v1:completion-batch')
|
||||
|
||||
# Enable the waffle flag for all tests
|
||||
_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, True)
|
||||
_overrider.__enter__()
|
||||
self.addCleanup(_overrider.__exit__, None, None, None)
|
||||
|
||||
# Create course
|
||||
self.course = CourseFactory.create(org='TestX', number='101', display_name='Test')
|
||||
self.problem = ItemFactory.create(
|
||||
parent=self.course,
|
||||
category="problem",
|
||||
display_name="Test Problem",
|
||||
)
|
||||
update_course_structure(unicode(self.course.id))
|
||||
|
||||
# Create users
|
||||
self.staff_user = UserFactory(is_staff=True)
|
||||
self.enrolled_user = UserFactory(username=self.ENROLLED_USERNAME)
|
||||
self.unenrolled_user = UserFactory(username=self.UNENROLLED_USERNAME)
|
||||
|
||||
# Enrol one user in the course
|
||||
CourseEnrollmentFactory.create(user=self.enrolled_user, course_id=self.course.id)
|
||||
|
||||
# Login the enrolled user by for all tests
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.enrolled_user)
|
||||
|
||||
def test_enable_completion_tracking(self):
|
||||
"""
|
||||
Test response when the waffle switch is disabled (default).
|
||||
"""
|
||||
with waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, False):
|
||||
response = self.client.post(self.url, {'username': self.ENROLLED_USERNAME}, format='json')
|
||||
self.assertEqual(response.data, {
|
||||
"detail":
|
||||
"BlockCompletion.objects.submit_batch_completion should not be called when the feature is disabled."
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@ddt.data(
|
||||
# Valid submission
|
||||
(
|
||||
{
|
||||
'username': ENROLLED_USERNAME,
|
||||
'course_key': COURSE_KEY,
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 200, {'detail': 'ok'}
|
||||
),
|
||||
# Blocks list can be empty, though it's a no-op
|
||||
(
|
||||
{
|
||||
'username': ENROLLED_USERNAME,
|
||||
'course_key': COURSE_KEY,
|
||||
'blocks': [],
|
||||
}, 200, {"detail": "ok"}
|
||||
),
|
||||
# Course must be a valid key
|
||||
(
|
||||
{
|
||||
'username': ENROLLED_USERNAME,
|
||||
'course_key': "not:a:course:key",
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 400, {"detail": "Invalid course key: not:a:course:key"}
|
||||
),
|
||||
# Block not in course
|
||||
(
|
||||
{
|
||||
'username': ENROLLED_USERNAME,
|
||||
'course_key': COURSE_KEY,
|
||||
'blocks': {
|
||||
'some:other:block': 1.0,
|
||||
}
|
||||
}, 400, {"detail": "Block with key: 'some:other:block' is not in course {}".format(COURSE_KEY)}
|
||||
),
|
||||
# Course key is required
|
||||
(
|
||||
{
|
||||
'username': ENROLLED_USERNAME,
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 400, {"detail": "Key 'course_key' not found."}
|
||||
),
|
||||
# Blocks is required
|
||||
(
|
||||
{
|
||||
'username': ENROLLED_USERNAME,
|
||||
'course_key': COURSE_KEY,
|
||||
}, 400, {"detail": "Key 'blocks' not found."}
|
||||
),
|
||||
# Ordinary users can only update their own completions
|
||||
(
|
||||
{
|
||||
'username': UNENROLLED_USERNAME,
|
||||
'course_key': COURSE_KEY,
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 403, {"detail": "You do not have permission to perform this action."}
|
||||
),
|
||||
# Username is required
|
||||
(
|
||||
{
|
||||
'course_key': COURSE_KEY,
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 403, {"detail": 'You do not have permission to perform this action.'}
|
||||
),
|
||||
# Course does not exist
|
||||
(
|
||||
{
|
||||
'username': ENROLLED_USERNAME,
|
||||
'course_key': 'TestX/101/Test2',
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 404, {"detail": "CourseStructure matching query does not exist."}
|
||||
),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_batch_submit(self, payload, expected_status, expected_data):
|
||||
"""
|
||||
Test the batch submission response for student users.
|
||||
"""
|
||||
response = self.client.post(self.url, payload, format='json')
|
||||
self.assertEqual(response.data, expected_data)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
|
||||
@ddt.data(
|
||||
# Staff can submit completion on behalf of other users
|
||||
(
|
||||
{
|
||||
'username': ENROLLED_USERNAME,
|
||||
'course_key': COURSE_KEY,
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 200, {'detail': 'ok'}
|
||||
),
|
||||
# User must be enrolled in the course
|
||||
(
|
||||
{
|
||||
'username': UNENROLLED_USERNAME,
|
||||
'course_key': COURSE_KEY,
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 400, {"detail": "User is not enrolled in course."}
|
||||
),
|
||||
# Username is required
|
||||
(
|
||||
{
|
||||
'course_key': COURSE_KEY,
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 400, {"detail": "Key 'username' not found."}
|
||||
),
|
||||
# User must not exist
|
||||
(
|
||||
{
|
||||
'username': 'doesntexist',
|
||||
'course_key': COURSE_KEY,
|
||||
'blocks': {
|
||||
BLOCK_KEY: 1.0,
|
||||
}
|
||||
}, 404, {"detail": 'User matching query does not exist.'}
|
||||
),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_batch_submit_staff(self, payload, expected_status, expected_data):
|
||||
"""
|
||||
Test the batch submission response when logged in as a staff user.
|
||||
"""
|
||||
self.client.force_authenticate(user=self.staff_user)
|
||||
response = self.client.post(self.url, payload, format='json')
|
||||
self.assertEqual(response.data, expected_data)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
10
lms/djangoapps/completion/api/v1/urls.py
Normal file
10
lms/djangoapps/completion/api/v1/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
API v1 URLs.
|
||||
"""
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^completion-batch', views.CompletionBatchView.as_view(), name='completion-batch'),
|
||||
]
|
||||
136
lms/djangoapps/completion/api/v1/views.py
Normal file
136
lms/djangoapps/completion/api/v1/views.py
Normal file
@@ -0,0 +1,136 @@
|
||||
""" API v1 views. """
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError, ObjectDoesNotExist
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.db import DatabaseError
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import permissions
|
||||
from rest_framework import status
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
|
||||
from lms.djangoapps.completion.models import BlockCompletion
|
||||
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
|
||||
from openedx.core.lib.api.permissions import IsStaffOrOwner
|
||||
from student.models import CourseEnrollment
|
||||
from completion import waffle
|
||||
|
||||
|
||||
class CompletionBatchView(APIView):
|
||||
"""
|
||||
Handles API requests to submit batch completions.
|
||||
"""
|
||||
permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,)
|
||||
REQUIRED_KEYS = ['username', 'course_key', 'blocks']
|
||||
|
||||
def _validate_and_parse(self, batch_object):
|
||||
"""
|
||||
Performs validation on the batch object to make sure it is in the proper format.
|
||||
|
||||
Parameters:
|
||||
* batch_object: The data provided to a POST. The expected format is the following:
|
||||
{
|
||||
"username": "username",
|
||||
"course_key": "course-key",
|
||||
"blocks": {
|
||||
"block_key1": 0.0,
|
||||
"block_key2": 1.0,
|
||||
"block_key3": 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Return Value:
|
||||
* tuple: (User, CourseKey, List of tuples (UsageKey, completion_float)
|
||||
|
||||
Raises:
|
||||
|
||||
django.core.exceptions.ValidationError:
|
||||
If any aspect of validation fails a ValidationError is raised.
|
||||
|
||||
ObjectDoesNotExist:
|
||||
If a database object cannot be found an ObjectDoesNotExist is raised.
|
||||
"""
|
||||
if not waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING):
|
||||
raise ValidationError(
|
||||
_("BlockCompletion.objects.submit_batch_completion should not be called when the feature is disabled.")
|
||||
)
|
||||
|
||||
for key in self.REQUIRED_KEYS:
|
||||
if key not in batch_object:
|
||||
raise ValidationError(_("Key '{key}' not found.".format(key=key)))
|
||||
|
||||
username = batch_object['username']
|
||||
user = User.objects.get(username=username)
|
||||
|
||||
course_key = batch_object['course_key']
|
||||
try:
|
||||
course_key_obj = CourseKey.from_string(course_key)
|
||||
except InvalidKeyError:
|
||||
raise ValidationError(_("Invalid course key: {}").format(course_key))
|
||||
course_structure = CourseStructure.objects.get(course_id=course_key_obj)
|
||||
|
||||
if not CourseEnrollment.is_enrolled(user, course_key_obj):
|
||||
raise ValidationError(_('User is not enrolled in course.'))
|
||||
|
||||
blocks = batch_object['blocks']
|
||||
block_objs = []
|
||||
for block_key in blocks:
|
||||
if block_key not in course_structure.structure['blocks'].keys():
|
||||
raise ValidationError(_("Block with key: '{key}' is not in course {course}")
|
||||
.format(key=block_key, course=course_key))
|
||||
|
||||
block_key_obj = UsageKey.from_string(block_key)
|
||||
completion = float(blocks[block_key])
|
||||
block_objs.append((block_key_obj, completion))
|
||||
|
||||
return user, course_key_obj, block_objs
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Inserts a batch of completions.
|
||||
|
||||
REST Endpoint Format:
|
||||
{
|
||||
"username": "username",
|
||||
"course_key": "course-key",
|
||||
"blocks": {
|
||||
"block_key1": 0.0,
|
||||
"block_key2": 1.0,
|
||||
"block_key3": 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
**Returns**
|
||||
|
||||
A Response object, with an appropriate status code.
|
||||
|
||||
If successful, status code is 200.
|
||||
{
|
||||
"detail" : _("ok")
|
||||
}
|
||||
|
||||
Otherwise, a 400 or 404 may be returned, and the "detail" content will explain the error.
|
||||
|
||||
"""
|
||||
batch_object = request.data or {}
|
||||
try:
|
||||
user, course_key, blocks = self._validate_and_parse(batch_object)
|
||||
BlockCompletion.objects.submit_batch_completion(user, course_key, blocks)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return Response({
|
||||
"detail": exc.message,
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except ObjectDoesNotExist as exc:
|
||||
return Response({
|
||||
"detail": exc.message,
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
except DatabaseError as exc:
|
||||
return Response({
|
||||
"detail": exc.message,
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
return Response({"detail": _("ok")}, status=status.HTTP_200_OK)
|
||||
Reference in New Issue
Block a user