Commit 14b8c12e by Clinton Blackburn

Added Studio API endpoint to update course image

LEARNER-2468
parent 317f719d
......@@ -2,12 +2,18 @@ import six
from django.contrib.auth import get_user_model
from django.db import transaction
from rest_framework import serializers
from rest_framework.fields import get_attribute
from rest_framework.fields import empty
from cms.djangoapps.contentstore.views.course import create_new_course, get_course_and_check_access, rerun_course
from contentstore.views.assets import update_course_run_asset
from openedx.core.lib.courses import course_image_url
from student.models import CourseAccessRole
from xmodule.modulestore.django import modulestore
IMAGE_TYPES = {
'image/jpeg': 'jpg',
'image/png': 'png',
}
User = get_user_model()
......@@ -53,10 +59,45 @@ class CourseRunTeamSerializerMixin(serializers.Serializer):
])
def image_is_jpeg_or_png(value):
content_type = value.content_type
if content_type not in IMAGE_TYPES.keys():
raise serializers.ValidationError(
'Only JPEG and PNG image types are supported. {} is not valid'.format(content_type))
class CourseRunImageField(serializers.ImageField):
default_validators = [image_is_jpeg_or_png]
def get_attribute(self, instance):
return course_image_url(instance)
def to_representation(self, value):
# Value will always be the URL path of the image.
request = self.context['request']
return request.build_absolute_uri(value)
class CourseRunImageSerializer(serializers.Serializer):
# We set an empty default to prevent the parent serializer from attempting
# to save this value to the Course object.
card_image = CourseRunImageField(source='course_image', default=empty)
def update(self, instance, validated_data):
course_image = validated_data['course_image']
course_image.name = 'course_image.' + IMAGE_TYPES[course_image.content_type]
update_course_run_asset(instance.id, course_image)
instance.course_image = course_image.name
modulestore().update_item(instance, self.context['request'].user.id)
return instance
class CourseRunSerializer(CourseRunTeamSerializerMixin, serializers.Serializer):
id = serializers.CharField(read_only=True)
title = serializers.CharField(source='display_name')
schedule = CourseRunScheduleSerializer(source='*', required=False)
images = CourseRunImageSerializer(source='*', required=False)
def update(self, instance, validated_data):
team = validated_data.pop('team', [])
......
import datetime
import pytz
from django.test import RequestFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from openedx.core.lib.courses import course_image_url
from student.roles import CourseInstructorRole, CourseStaffRole
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..utils import serialize_datetime
from ...serializers.course_runs import CourseRunSerializer
......@@ -23,7 +25,8 @@ class CourseRunSerializerTests(ModuleStoreTestCase):
staff = UserFactory()
CourseStaffRole(course.id).add_users(staff)
serializer = CourseRunSerializer(course)
request = RequestFactory().get('')
serializer = CourseRunSerializer(course, context={'request': request})
expected = {
'id': str(course.id),
'title': course.display_name,
......@@ -43,5 +46,8 @@ class CourseRunSerializerTests(ModuleStoreTestCase):
'role': 'staff',
},
],
'images': {
'card_image': request.build_absolute_uri(course_image_url(course)),
}
}
assert serializer.data == expected
import datetime
import pytz
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.urlresolvers import reverse
from django.test import RequestFactory
from opaque_keys.edx.keys import CourseKey
from rest_framework.test import APIClient
from student.models import CourseAccessRole
from student.tests.factories import AdminFactory, TEST_PASSWORD, UserFactory
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ToyCourseFactory
from openedx.core.lib.courses import course_image_url
from student.models import CourseAccessRole
from student.tests.factories import AdminFactory, TEST_PASSWORD, UserFactory
from ..utils import serialize_datetime
from ...serializers.course_runs import CourseRunSerializer
......@@ -34,6 +40,9 @@ class CourseRunViewSetTests(ModuleStoreTestCase):
CourseAccessRole.objects.get(course_id=course_run.id, user=user, role=role)
assert CourseAccessRole.objects.filter(course_id=course_run.id).count() == 1
def get_serializer_context(self):
return {'request': RequestFactory().get('')}
def test_without_authentication(self):
self.client.logout()
response = self.client.get(self.list_url)
......@@ -53,14 +62,14 @@ class CourseRunViewSetTests(ModuleStoreTestCase):
# Order matters for the assertion
course_runs = sorted(course_runs, key=lambda course_run: str(course_run.id))
actual = sorted(response.data, key=lambda course_run: course_run['id'])
assert actual == CourseRunSerializer(course_runs, many=True).data
assert actual == CourseRunSerializer(course_runs, many=True, context=self.get_serializer_context()).data
def test_retrieve(self):
course_run = CourseFactory()
url = reverse('api:v1:course_run-detail', kwargs={'pk': str(course_run.id)})
response = self.client.get(url)
assert response.status_code == 200
assert response.data == CourseRunSerializer(course_run).data
assert response.data == CourseRunSerializer(course_run, context=self.get_serializer_context()).data
def test_retrieve_not_found(self):
url = reverse('api:v1:course_run-detail', kwargs={'pk': 'course-v1:TestX+Test101x+1T2017'})
......@@ -104,7 +113,7 @@ class CourseRunViewSetTests(ModuleStoreTestCase):
self.assert_access_role(course_run, user, role)
course_run = modulestore().get_course(course_run.id)
assert response.data == CourseRunSerializer(course_run).data
assert response.data == CourseRunSerializer(course_run, context=self.get_serializer_context()).data
assert course_run.display_name == title
self.assert_course_run_schedule(course_run, start, end, enrollment_start, enrollment_end)
......@@ -185,6 +194,39 @@ class CourseRunViewSetTests(ModuleStoreTestCase):
self.assert_course_run_schedule(course_run, start, end, enrollment_start, enrollment_end)
self.assert_access_role(course_run, user, role)
def test_images_upload(self):
# http://www.django-rest-framework.org/api-guide/parsers/#fileuploadparser
course_run = CourseFactory()
expected_filename = 'course_image.png'
content_key = StaticContent.compute_location(course_run.id, expected_filename)
assert course_run.course_image != expected_filename
try:
contentstore().find(content_key)
self.fail('No image should be associated with a new course run.')
except NotFoundError:
pass
url = reverse('api:v1:course_run-images', kwargs={'pk': str(course_run.id)})
# PNG. Single black pixel
content = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS' \
b'\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178U\x00\x00\x00\x00IEND\xaeB`\x82'
# We are intentionally passing the incorrect JPEG extension here
upload = SimpleUploadedFile('card_image.jpg', content, content_type='image/png')
response = self.client.post(url, {'card_image': upload}, format='multipart')
assert response.status_code == 200
course_run = modulestore().get_course(course_run.id)
assert course_run.course_image == expected_filename
expected = {'card_image': RequestFactory().get('').build_absolute_uri(course_image_url(course_run))}
assert response.data == expected
# There should now be an image stored
contentstore().find(content_key)
def test_rerun(self):
course_run = ToyCourseFactory()
start = datetime.datetime.now(pytz.UTC).replace(microsecond=0)
......
......@@ -2,13 +2,18 @@ from django.conf import settings
from django.http import Http404
from edx_rest_framework_extensions.authentication import JwtAuthentication
from opaque_keys.edx.keys import CourseKey
from rest_framework import permissions, status, viewsets
from rest_framework import parsers, permissions, status, viewsets
from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import detail_route
from rest_framework.response import Response
from contentstore.views.course import _accessible_courses_iter, get_course_and_check_access
from ..serializers.course_runs import CourseRunCreateSerializer, CourseRunRerunSerializer, CourseRunSerializer
from ..serializers.course_runs import (
CourseRunCreateSerializer,
CourseRunImageSerializer,
CourseRunRerunSerializer,
CourseRunSerializer
)
class CourseRunViewSet(viewsets.ViewSet):
......@@ -66,6 +71,19 @@ class CourseRunViewSet(viewsets.ViewSet):
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@detail_route(
methods=['post', 'put'],
parser_classes=(parsers.FormParser, parsers.MultiPartParser,),
serializer_class=CourseRunImageSerializer)
def images(self, request, pk=None):
course_run_key = CourseKey.from_string(pk)
user = request.user
course_run = self.get_course_run_or_raise_404(course_run_key, user)
serializer = CourseRunImageSerializer(course_run, data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@detail_route(methods=['post'])
def rerun(self, request, pk=None):
course_run_key = CourseKey.from_string(pk)
......
......@@ -9,25 +9,26 @@ from django.core.exceptions import PermissionDenied
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.utils.translation import ugettext as _
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods, require_POST
from django.views.decorators.http import require_POST, require_http_methods
from opaque_keys.edx.keys import AssetKey, CourseKey
from pymongo import ASCENDING, DESCENDING
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from contentstore.utils import reverse_course_url
from contentstore.views.exception import AssetNotFoundException
from contentstore.views.exception import AssetNotFoundException, AssetSizeTooLargeException
from edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.contentserver.caching import del_cached_content
from student.auth import has_course_author_access
from util.date_utils import get_default_time_display
from util.json_request import JsonResponse
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
__all__ = ['assets_handler']
# pylint: disable=unused-argument
......@@ -204,51 +205,19 @@ def get_file_size(upload_file):
return upload_file.size
@require_POST
@ensure_csrf_cookie
@login_required
def _upload_asset(request, course_key):
'''
This method allows for POST uploading of files into the course asset
library, which will be supported by GridFS in MongoDB.
'''
# Does the course actually exist?!? Get anything from it to prove its
# existence
try:
modulestore().get_course(course_key)
except ItemNotFoundError:
# no return it as a Bad Request response
logging.error("Could not find course: %s", course_key)
return HttpResponseBadRequest()
# compute a 'filename' which is similar to the location formatting, we're
# using the 'filename' nomenclature since we're using a FileSystem paradigm
# here. We're just imposing the Location string formatting expectations to
# keep things a bit more consistent
upload_file = request.FILES['file']
def update_course_run_asset(course_key, upload_file):
filename = upload_file.name
mime_type = upload_file.content_type
size = get_file_size(upload_file)
# If file is greater than a specified size, reject the upload
# request and send a message to the user. Note that since
# the front-end may batch large file uploads in smaller chunks,
# we validate the file-size on the front-end in addition to
# validating on the backend. (see cms/static/js/views/assets.js)
max_file_size_in_bytes = settings.MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB * 1000 ** 2
max_size_in_mb = settings.MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB
max_file_size_in_bytes = max_size_in_mb * 1000 ** 2
if size > max_file_size_in_bytes:
return JsonResponse({
'error': _(
'File {filename} exceeds maximum size of '
'{size_mb} MB. Please follow the instructions here '
'to upload a file elsewhere and link to it instead: '
'{faq_url}'
).format(
filename=filename,
size_mb=settings.MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB,
faq_url=settings.MAX_ASSET_UPLOAD_FILE_SIZE_URL,
)
}, status=413)
msg = 'File {filename} exceeds the maximum size of {max_size_in_mb} MB.'.format(
filename=filename,
max_size_in_mb=max_size_in_mb
)
raise AssetSizeTooLargeException(msg)
content_loc = StaticContent.compute_location(course_key, filename)
......@@ -261,15 +230,12 @@ def _upload_asset(request, course_key):
content = sc_partial(upload_file.read())
tempfile_path = None
# first let's see if a thumbnail can be created
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(
content,
tempfile_path=tempfile_path,
)
# Verify a thumbnail can be created
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content, tempfile_path=tempfile_path)
# delete cached thumbnail even if one couldn't be created this time (else
# the old thumbnail will continue to show)
# delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show)
del_cached_content(thumbnail_location)
# now store thumbnail location only if we could create it
if thumbnail_content is not None:
content.thumbnail_location = thumbnail_location
......@@ -278,10 +244,41 @@ def _upload_asset(request, course_key):
contentstore().save(content)
del_cached_content(content.location)
return content
@require_POST
@ensure_csrf_cookie
@login_required
def _upload_asset(request, course_key):
"""
This method allows for POST uploading of files into the course asset
library, which will be supported by GridFS in MongoDB.
"""
# Does the course actually exist?!? Get anything from it to prove its
# existence
try:
modulestore().get_course(course_key)
except ItemNotFoundError:
# no return it as a Bad Request response
logging.error("Could not find course: %s", course_key)
return HttpResponseBadRequest()
# compute a 'filename' which is similar to the location formatting, we're
# using the 'filename' nomenclature since we're using a FileSystem paradigm
# here. We're just imposing the Location string formatting expectations to
# keep things a bit more consistent
upload_file = request.FILES['file']
try:
content = update_course_run_asset(course_key, upload_file)
except AssetSizeTooLargeException as ex:
return JsonResponse({'error': ex.message}, status=413)
# readback the saved content - we need the database timestamp
readback = contentstore().find(content.location)
locked = getattr(content, 'locked', False)
response_payload = {
return JsonResponse({
'asset': _get_asset_json(
content.name,
content.content_type,
......@@ -291,9 +288,7 @@ def _upload_asset(request, course_key):
locked
),
'msg': _('Upload completed')
}
return JsonResponse(response_payload)
})
@require_http_methods(("DELETE", "POST", "PUT"))
......
......@@ -8,3 +8,10 @@ class AssetNotFoundException(Exception):
Raised when asset not found
"""
pass
class AssetSizeTooLargeException(Exception):
"""
Raised when the size of an uploaded asset exceeds the maximum size limit.
"""
pass
......@@ -636,12 +636,16 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
"""
# first make sure an existing course doesn't already exist in the mapping
course_key = self.make_course_key(org, course, run)
log.info('Creating course run %s...', course_key)
if course_key in self.mappings and self.mappings[course_key].has_course(course_key):
log.error('Cannot create course run %s. It already exists!', course_key)
raise DuplicateCourseError(course_key, course_key)
# create the course
store = self._verify_modulestore_support(None, 'create_course')
course = store.create_course(org, course, run, user_id, **kwargs)
log.info('Course run %s created successfully!', course_key)
# add new course to the mapping
self.mappings[course_key] = store
......
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