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
from django.contrib.auth.models import User
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 model_utils.models import TimeStampedModel
from opaque_keys.edx.keys import CourseKey
......@@ -34,7 +34,7 @@ class BlockCompletionManager(models.Manager):
"""
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):
......@@ -87,14 +87,14 @@ class BlockCompletionManager(models.Manager):
)
if waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING):
obj, isnew = self.get_or_create(
obj, is_new = self.get_or_create(
user=user,
course_key=course_key,
block_type=block_type,
block_key=block_key,
defaults={'completion': completion},
)
if not isnew and obj.completion != completion:
if not is_new and obj.completion != completion:
obj.completion = completion
obj.full_clean()
obj.save()
......@@ -103,7 +103,44 @@ class BlockCompletionManager(models.Manager):
raise RuntimeError(
"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):
......
......@@ -6,9 +6,9 @@ from __future__ import absolute_import, division, print_function, unicode_litera
from django.core.exceptions import ValidationError
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 waffle
......@@ -142,3 +142,48 @@ class CompletionDisabledTestCase(CompletionSetUpMixin, TestCase):
completion=0.9,
)
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 = [
# Course API
url(r'^api/courses/', include('course_api.urls')),
# Completion API
url(r'^api/completion/', include('completion.api.urls', namespace='completion_api')),
# User API endpoints
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