Unverified Commit ca46bfcf by Cliff Dyer Committed by GitHub

Merge pull request #16688 from open-craft/cornwell/rest-endpoint

MCKIN-5896 - Create a REST endpoint for batch-uploading offline completions. 
parents dd8e879e fae69588
"""
Api URLs.
"""
from django.conf.urls import include, url
urlpatterns = [
url(r'^v1/', include('lms.djangoapps.completion.api.v1.urls', namespace='v1')),
]
# -*- 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)
"""
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'),
]
""" 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)
...@@ -6,7 +6,7 @@ from __future__ import absolute_import, division, print_function, unicode_litera ...@@ -6,7 +6,7 @@ from __future__ import absolute_import, division, print_function, unicode_litera
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models, transaction, connection
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
...@@ -34,7 +34,7 @@ class BlockCompletionManager(models.Manager): ...@@ -34,7 +34,7 @@ class BlockCompletionManager(models.Manager):
""" """
Custom manager for BlockCompletion model. Custom manager for BlockCompletion model.
Adds submit_completion method. Adds submit_completion and submit_batch_completion methods.
""" """
def submit_completion(self, user, course_key, block_key, completion): def submit_completion(self, user, course_key, block_key, completion):
...@@ -87,14 +87,14 @@ class BlockCompletionManager(models.Manager): ...@@ -87,14 +87,14 @@ class BlockCompletionManager(models.Manager):
) )
if waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING): if waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING):
obj, isnew = self.get_or_create( obj, is_new = self.get_or_create(
user=user, user=user,
course_key=course_key, course_key=course_key,
block_type=block_type, block_type=block_type,
block_key=block_key, block_key=block_key,
defaults={'completion': completion}, defaults={'completion': completion},
) )
if not isnew and obj.completion != completion: if not is_new and obj.completion != completion:
obj.completion = completion obj.completion = completion
obj.full_clean() obj.full_clean()
obj.save() obj.save()
...@@ -103,7 +103,44 @@ class BlockCompletionManager(models.Manager): ...@@ -103,7 +103,44 @@ class BlockCompletionManager(models.Manager):
raise RuntimeError( raise RuntimeError(
"BlockCompletion.objects.submit_completion should not be called when the feature is disabled." "BlockCompletion.objects.submit_completion should not be called when the feature is disabled."
) )
return obj, isnew return obj, is_new
@transaction.atomic()
def submit_batch_completion(self, user, course_key, blocks):
"""
Performs a batch insertion of completion objects.
Parameters:
* user (django.contrib.auth.models.User): The user for whom the
completions are being submitted.
* course_key (opaque_keys.edx.keys.CourseKey): The course in
which the submitted blocks are found.
* blocks: A list of tuples of UsageKey to float completion values.
(float in range [0.0, 1.0]): The fractional completion
value of the block (0.0 = incomplete, 1.0 = complete).
Return Value:
Dict of (BlockCompletion, bool): A dictionary with a
BlockCompletion object key and a value of bool. The boolean value
indicates whether the object was newly created by this call.
Raises:
ValueError:
If the wrong type is passed for one of the parameters.
django.core.exceptions.ValidationError:
If a float is passed that is not between 0.0 and 1.0.
django.db.DatabaseError:
If there was a problem getting, creating, or updating the
BlockCompletion record in the database.
"""
block_completions = {}
for block, completion in blocks:
(block_completion, is_new) = self.submit_completion(user, course_key, block, completion)
block_completions[block_completion] = is_new
return block_completions
class BlockCompletion(TimeStampedModel, models.Model): class BlockCompletion(TimeStampedModel, models.Model):
......
...@@ -6,9 +6,9 @@ from __future__ import absolute_import, division, print_function, unicode_litera ...@@ -6,9 +6,9 @@ from __future__ import absolute_import, division, print_function, unicode_litera
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import TestCase
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey, CourseKey
from student.tests.factories import UserFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from .. import models from .. import models
from .. import waffle from .. import waffle
...@@ -142,3 +142,48 @@ class CompletionDisabledTestCase(CompletionSetUpMixin, TestCase): ...@@ -142,3 +142,48 @@ class CompletionDisabledTestCase(CompletionSetUpMixin, TestCase):
completion=0.9, completion=0.9,
) )
self.assertEqual(models.BlockCompletion.objects.count(), 1) self.assertEqual(models.BlockCompletion.objects.count(), 1)
class SubmitBatchCompletionTestCase(TestCase):
"""
Test that BlockCompletion.objects.submit_batch_completion has the desired
semantics.
"""
def setUp(self):
super(SubmitBatchCompletionTestCase, self).setUp()
_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, True)
_overrider.__enter__()
self.addCleanup(_overrider.__exit__, None, None, None)
self.block_key = UsageKey.from_string('block-v1:edx+test+run+type@video+block@doggos')
self.course_key_obj = CourseKey.from_string('course-v1:edx+test+run')
self.user = UserFactory()
CourseEnrollmentFactory.create(user=self.user, course_id=unicode(self.course_key_obj))
def test_submit_batch_completion(self):
blocks = [(self.block_key, 1.0)]
models.BlockCompletion.objects.submit_batch_completion(self.user, self.course_key_obj, blocks)
self.assertEqual(models.BlockCompletion.objects.count(), 1)
self.assertEqual(models.BlockCompletion.objects.last().completion, 1.0)
def test_submit_batch_completion_without_waffle(self):
with waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, False):
with self.assertRaises(RuntimeError):
blocks = [(self.block_key, 1.0)]
models.BlockCompletion.objects.submit_batch_completion(self.user, self.course_key_obj, blocks)
def test_submit_batch_completion_with_same_block_new_completion_value(self):
blocks = [(self.block_key, 0.0)]
self.assertEqual(models.BlockCompletion.objects.count(), 0)
models.BlockCompletion.objects.submit_batch_completion(self.user, self.course_key_obj, blocks)
self.assertEqual(models.BlockCompletion.objects.count(), 1)
model = models.BlockCompletion.objects.first()
self.assertEqual(model.completion, 0.0)
blocks = [
(UsageKey.from_string('block-v1:edx+test+run+type@video+block@doggos'), 1.0),
]
models.BlockCompletion.objects.submit_batch_completion(self.user, self.course_key_obj, blocks)
self.assertEqual(models.BlockCompletion.objects.count(), 1)
model = models.BlockCompletion.objects.first()
self.assertEqual(model.completion, 1.0)
...@@ -100,6 +100,9 @@ urlpatterns = [ ...@@ -100,6 +100,9 @@ urlpatterns = [
# Course API # Course API
url(r'^api/courses/', include('course_api.urls')), url(r'^api/courses/', include('course_api.urls')),
# Completion API
url(r'^api/completion/', include('completion.api.urls', namespace='completion_api')),
# User API endpoints # User API endpoints
url(r'^api/user/', include('openedx.core.djangoapps.user_api.urls')), url(r'^api/user/', include('openedx.core.djangoapps.user_api.urls')),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment