Commit 14b8c12e by Clinton Blackburn

Added Studio API endpoint to update course image

LEARNER-2468
parent 317f719d
...@@ -2,12 +2,18 @@ import six ...@@ -2,12 +2,18 @@ import six
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from rest_framework import serializers 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 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 student.models import CourseAccessRole
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
IMAGE_TYPES = {
'image/jpeg': 'jpg',
'image/png': 'png',
}
User = get_user_model() User = get_user_model()
...@@ -53,10 +59,45 @@ class CourseRunTeamSerializerMixin(serializers.Serializer): ...@@ -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): class CourseRunSerializer(CourseRunTeamSerializerMixin, serializers.Serializer):
id = serializers.CharField(read_only=True) id = serializers.CharField(read_only=True)
title = serializers.CharField(source='display_name') title = serializers.CharField(source='display_name')
schedule = CourseRunScheduleSerializer(source='*', required=False) schedule = CourseRunScheduleSerializer(source='*', required=False)
images = CourseRunImageSerializer(source='*', required=False)
def update(self, instance, validated_data): def update(self, instance, validated_data):
team = validated_data.pop('team', []) team = validated_data.pop('team', [])
......
import datetime import datetime
import pytz 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.roles import CourseInstructorRole, CourseStaffRole
from student.tests.factories import UserFactory 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 ..utils import serialize_datetime
from ...serializers.course_runs import CourseRunSerializer from ...serializers.course_runs import CourseRunSerializer
...@@ -23,7 +25,8 @@ class CourseRunSerializerTests(ModuleStoreTestCase): ...@@ -23,7 +25,8 @@ class CourseRunSerializerTests(ModuleStoreTestCase):
staff = UserFactory() staff = UserFactory()
CourseStaffRole(course.id).add_users(staff) CourseStaffRole(course.id).add_users(staff)
serializer = CourseRunSerializer(course) request = RequestFactory().get('')
serializer = CourseRunSerializer(course, context={'request': request})
expected = { expected = {
'id': str(course.id), 'id': str(course.id),
'title': course.display_name, 'title': course.display_name,
...@@ -43,5 +46,8 @@ class CourseRunSerializerTests(ModuleStoreTestCase): ...@@ -43,5 +46,8 @@ class CourseRunSerializerTests(ModuleStoreTestCase):
'role': 'staff', 'role': 'staff',
}, },
], ],
'images': {
'card_image': request.build_absolute_uri(course_image_url(course)),
}
} }
assert serializer.data == expected assert serializer.data == expected
import datetime import datetime
import pytz import pytz
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import RequestFactory
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from rest_framework.test import APIClient from rest_framework.test import APIClient
from xmodule.contentstore.content import StaticContent
from student.models import CourseAccessRole from xmodule.contentstore.django import contentstore
from student.tests.factories import AdminFactory, TEST_PASSWORD, UserFactory from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ToyCourseFactory 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 ..utils import serialize_datetime
from ...serializers.course_runs import CourseRunSerializer from ...serializers.course_runs import CourseRunSerializer
...@@ -34,6 +40,9 @@ class CourseRunViewSetTests(ModuleStoreTestCase): ...@@ -34,6 +40,9 @@ class CourseRunViewSetTests(ModuleStoreTestCase):
CourseAccessRole.objects.get(course_id=course_run.id, user=user, role=role) CourseAccessRole.objects.get(course_id=course_run.id, user=user, role=role)
assert CourseAccessRole.objects.filter(course_id=course_run.id).count() == 1 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): def test_without_authentication(self):
self.client.logout() self.client.logout()
response = self.client.get(self.list_url) response = self.client.get(self.list_url)
...@@ -53,14 +62,14 @@ class CourseRunViewSetTests(ModuleStoreTestCase): ...@@ -53,14 +62,14 @@ class CourseRunViewSetTests(ModuleStoreTestCase):
# Order matters for the assertion # Order matters for the assertion
course_runs = sorted(course_runs, key=lambda course_run: str(course_run.id)) course_runs = sorted(course_runs, key=lambda course_run: str(course_run.id))
actual = sorted(response.data, key=lambda course_run: 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): def test_retrieve(self):
course_run = CourseFactory() course_run = CourseFactory()
url = reverse('api:v1:course_run-detail', kwargs={'pk': str(course_run.id)}) url = reverse('api:v1:course_run-detail', kwargs={'pk': str(course_run.id)})
response = self.client.get(url) response = self.client.get(url)
assert response.status_code == 200 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): def test_retrieve_not_found(self):
url = reverse('api:v1:course_run-detail', kwargs={'pk': 'course-v1:TestX+Test101x+1T2017'}) url = reverse('api:v1:course_run-detail', kwargs={'pk': 'course-v1:TestX+Test101x+1T2017'})
...@@ -104,7 +113,7 @@ class CourseRunViewSetTests(ModuleStoreTestCase): ...@@ -104,7 +113,7 @@ class CourseRunViewSetTests(ModuleStoreTestCase):
self.assert_access_role(course_run, user, role) self.assert_access_role(course_run, user, role)
course_run = modulestore().get_course(course_run.id) 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 assert course_run.display_name == title
self.assert_course_run_schedule(course_run, start, end, enrollment_start, enrollment_end) self.assert_course_run_schedule(course_run, start, end, enrollment_start, enrollment_end)
...@@ -185,6 +194,39 @@ class CourseRunViewSetTests(ModuleStoreTestCase): ...@@ -185,6 +194,39 @@ class CourseRunViewSetTests(ModuleStoreTestCase):
self.assert_course_run_schedule(course_run, start, end, enrollment_start, enrollment_end) self.assert_course_run_schedule(course_run, start, end, enrollment_start, enrollment_end)
self.assert_access_role(course_run, user, role) 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): def test_rerun(self):
course_run = ToyCourseFactory() course_run = ToyCourseFactory()
start = datetime.datetime.now(pytz.UTC).replace(microsecond=0) start = datetime.datetime.now(pytz.UTC).replace(microsecond=0)
......
...@@ -2,13 +2,18 @@ from django.conf import settings ...@@ -2,13 +2,18 @@ from django.conf import settings
from django.http import Http404 from django.http import Http404
from edx_rest_framework_extensions.authentication import JwtAuthentication from edx_rest_framework_extensions.authentication import JwtAuthentication
from opaque_keys.edx.keys import CourseKey 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.authentication import SessionAuthentication
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.response import Response from rest_framework.response import Response
from contentstore.views.course import _accessible_courses_iter, get_course_and_check_access 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): class CourseRunViewSet(viewsets.ViewSet):
...@@ -66,6 +71,19 @@ class CourseRunViewSet(viewsets.ViewSet): ...@@ -66,6 +71,19 @@ class CourseRunViewSet(viewsets.ViewSet):
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED) 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']) @detail_route(methods=['post'])
def rerun(self, request, pk=None): def rerun(self, request, pk=None):
course_run_key = CourseKey.from_string(pk) course_run_key = CourseKey.from_string(pk)
......
...@@ -9,25 +9,26 @@ from django.core.exceptions import PermissionDenied ...@@ -9,25 +9,26 @@ from django.core.exceptions import PermissionDenied
from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.decorators.csrf import ensure_csrf_cookie 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 opaque_keys.edx.keys import AssetKey, CourseKey
from pymongo import ASCENDING, DESCENDING 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.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 edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.contentserver.caching import del_cached_content from openedx.core.djangoapps.contentserver.caching import del_cached_content
from student.auth import has_course_author_access from student.auth import has_course_author_access
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
from util.json_request import JsonResponse 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'] __all__ = ['assets_handler']
# pylint: disable=unused-argument # pylint: disable=unused-argument
...@@ -204,51 +205,19 @@ def get_file_size(upload_file): ...@@ -204,51 +205,19 @@ def get_file_size(upload_file):
return upload_file.size return upload_file.size
@require_POST def update_course_run_asset(course_key, upload_file):
@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']
filename = upload_file.name filename = upload_file.name
mime_type = upload_file.content_type mime_type = upload_file.content_type
size = get_file_size(upload_file) size = get_file_size(upload_file)
# If file is greater than a specified size, reject the upload max_size_in_mb = settings.MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB
# request and send a message to the user. Note that since max_file_size_in_bytes = max_size_in_mb * 1000 ** 2
# 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
if size > max_file_size_in_bytes: if size > max_file_size_in_bytes:
return JsonResponse({ msg = 'File {filename} exceeds the maximum size of {max_size_in_mb} MB.'.format(
'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, filename=filename,
size_mb=settings.MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB, max_size_in_mb=max_size_in_mb
faq_url=settings.MAX_ASSET_UPLOAD_FILE_SIZE_URL,
) )
}, status=413) raise AssetSizeTooLargeException(msg)
content_loc = StaticContent.compute_location(course_key, filename) content_loc = StaticContent.compute_location(course_key, filename)
...@@ -261,15 +230,12 @@ def _upload_asset(request, course_key): ...@@ -261,15 +230,12 @@ def _upload_asset(request, course_key):
content = sc_partial(upload_file.read()) content = sc_partial(upload_file.read())
tempfile_path = None tempfile_path = None
# first let's see if a thumbnail can be created # Verify a thumbnail can be created
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail( (thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content, tempfile_path=tempfile_path)
content,
tempfile_path=tempfile_path,
)
# delete cached thumbnail even if one couldn't be created this time (else # delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show)
# the old thumbnail will continue to show)
del_cached_content(thumbnail_location) del_cached_content(thumbnail_location)
# now store thumbnail location only if we could create it # now store thumbnail location only if we could create it
if thumbnail_content is not None: if thumbnail_content is not None:
content.thumbnail_location = thumbnail_location content.thumbnail_location = thumbnail_location
...@@ -278,10 +244,41 @@ def _upload_asset(request, course_key): ...@@ -278,10 +244,41 @@ def _upload_asset(request, course_key):
contentstore().save(content) contentstore().save(content)
del_cached_content(content.location) 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 the saved content - we need the database timestamp
readback = contentstore().find(content.location) readback = contentstore().find(content.location)
locked = getattr(content, 'locked', False) locked = getattr(content, 'locked', False)
response_payload = { return JsonResponse({
'asset': _get_asset_json( 'asset': _get_asset_json(
content.name, content.name,
content.content_type, content.content_type,
...@@ -291,9 +288,7 @@ def _upload_asset(request, course_key): ...@@ -291,9 +288,7 @@ def _upload_asset(request, course_key):
locked locked
), ),
'msg': _('Upload completed') 'msg': _('Upload completed')
} })
return JsonResponse(response_payload)
@require_http_methods(("DELETE", "POST", "PUT")) @require_http_methods(("DELETE", "POST", "PUT"))
......
...@@ -8,3 +8,10 @@ class AssetNotFoundException(Exception): ...@@ -8,3 +8,10 @@ class AssetNotFoundException(Exception):
Raised when asset not found Raised when asset not found
""" """
pass 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): ...@@ -636,12 +636,16 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
""" """
# first make sure an existing course doesn't already exist in the mapping # first make sure an existing course doesn't already exist in the mapping
course_key = self.make_course_key(org, course, run) 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): 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) raise DuplicateCourseError(course_key, course_key)
# create the course # create the course
store = self._verify_modulestore_support(None, 'create_course') store = self._verify_modulestore_support(None, 'create_course')
course = store.create_course(org, course, run, user_id, **kwargs) 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 # add new course to the mapping
self.mappings[course_key] = store 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