Revert "edX Course/Library Import/Export API"

This reverts commit c94abd2705.
This commit is contained in:
Brandon DeRosier
2015-07-07 16:53:35 -04:00
committed by christopher lee
parent 783e83deb0
commit 2bfbda3c1e
20 changed files with 850 additions and 1392 deletions

View File

@@ -1,3 +0,0 @@
"""
Publishing API
"""

View File

@@ -1,3 +0,0 @@
"""
Course publishing API
"""

View File

@@ -1,3 +0,0 @@
"""
A models.py is required to make this an app (until we move to Django 1.7)
"""

View File

@@ -1,3 +0,0 @@
"""
Tests for course publishing API
"""

View File

@@ -1,477 +0,0 @@
"""
Unit tests for course import and export
"""
import copy
import json
import logging
import lxml
import os
import tarfile
import tempfile
from path import path # pylint: disable=no-name-in-module
from uuid import uuid4
from django.test.utils import override_settings
from django.conf import settings
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.xml_exporter import export_library_to_xml
from xmodule.modulestore.xml_importer import import_library_from_xml
from xmodule.modulestore import LIBRARY_ROOT
from django.core.urlresolvers import reverse
from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory
from .utils import CourseTestCase
from openedx.core.lib.extract_tar import safetar_extractall
from openedx.core.lib.tempdir import mkdtemp_clean
from student import auth
from student.roles import CourseInstructorRole, CourseStaffRole
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_{}'.format(
uuid4().hex
)
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
log = logging.getLogger(__name__)
def course_url(handler, course_key, **kwargs):
"""
Reverse a handler that uses a course key.
:param handler: a URL handler name
:param course_key: a CourseKey
:return: the reversed URL string of the handler with the given course key
"""
kwargs_for_reverse = {'course_key_string': course_key.id}
if kwargs:
kwargs_for_reverse.update(kwargs)
return reverse(
handler,
kwargs=kwargs_for_reverse
)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
class ImportTestCase(CourseTestCase):
"""
Unit tests for importing a course or library
"""
def setUp(self):
super(ImportTestCase, self).setUp()
self.url = course_url('course_import_export_handler', self.course)
self.content_dir = path(mkdtemp_clean())
# Create tar test files -----------------------------------------------
# OK course:
good_dir = tempfile.mkdtemp(dir=self.content_dir)
# test course being deeper down than top of tar file
embedded_dir = os.path.join(good_dir, "grandparent", "parent")
os.makedirs(os.path.join(embedded_dir, "course"))
with open(os.path.join(embedded_dir, "course.xml"), "w+") as f:
f.write('<course url_name="2013_Spring" org="EDx" course="0.00x"/>')
with open(os.path.join(embedded_dir, "course", "2013_Spring.xml"), "w+") as f:
f.write('<course></course>')
self.good_tar = os.path.join(self.content_dir, "good.tar.gz")
with tarfile.open(self.good_tar, "w:gz") as gtar:
gtar.add(good_dir)
# Bad course (no 'course.xml' file):
bad_dir = tempfile.mkdtemp(dir=self.content_dir)
path.joinpath(bad_dir, "bad.xml").touch()
self.bad_tar = os.path.join(self.content_dir, "bad.tar.gz")
with tarfile.open(self.bad_tar, "w:gz") as btar:
btar.add(bad_dir)
self.unsafe_common_dir = path(tempfile.mkdtemp(dir=self.content_dir))
def test_no_coursexml(self):
"""
Check that the response for a tar.gz import without a course.xml is
correct.
"""
with open(self.bad_tar) as btar:
resp = self.client.post(
self.url,
{
"name": self.bad_tar,
"course-data": [btar]
})
self.assertEquals(resp.status_code, 415)
# Check that `ImportStatus` returns the appropriate stage (i.e., the
# stage at which import failed).
resp_status = self.client.get(
course_url(
'course_import_status_handler',
self.course,
filename=os.path.split(self.bad_tar)[1]
)
)
obj = json.loads(resp_status.content)
self.assertIn("ImportStatus", obj)
self.assertEquals(obj["ImportStatus"], -2)
def test_with_coursexml(self):
"""
Check that the response for a tar.gz import with a course.xml is
correct.
"""
with open(self.good_tar) as gtar:
args = {"name": self.good_tar, "course-data": [gtar]}
resp = self.client.post(self.url, args)
self.assertEquals(resp.status_code, 200)
def test_import_in_existing_course(self):
"""
Check that course is imported successfully in existing course and users
have their access roles
"""
# Create a non_staff user and add it to course staff only
__, nonstaff_user = self.create_non_staff_authed_user_client()
auth.add_users(
self.user,
CourseStaffRole(self.course.id),
nonstaff_user
)
course = self.store.get_course(self.course.id)
self.assertIsNotNone(course)
display_name_before_import = course.display_name
# Check that global staff user can import course
with open(self.good_tar) as gtar:
args = {"name": self.good_tar, "course-data": [gtar]}
resp = self.client.post(self.url, args)
self.assertEquals(resp.status_code, 200)
course = self.store.get_course(self.course.id)
self.assertIsNotNone(course)
display_name_after_import = course.display_name
# Check that course display name have changed after import
self.assertNotEqual(
display_name_before_import,
display_name_after_import
)
# Now check that non_staff user has his same role
self.assertFalse(
CourseInstructorRole(self.course.id).has_user(nonstaff_user)
)
self.assertTrue(
CourseStaffRole(self.course.id).has_user(nonstaff_user)
)
# Now course staff user can also successfully import course
self.client.login(username=nonstaff_user.username, password='foo')
with open(self.good_tar) as gtar:
args = {"name": self.good_tar, "course-data": [gtar]}
resp = self.client.post(self.url, args)
self.assertEquals(resp.status_code, 200)
# Now check that non_staff user has his same role
self.assertFalse(
CourseInstructorRole(self.course.id).has_user(nonstaff_user)
)
self.assertTrue(
CourseStaffRole(self.course.id).has_user(nonstaff_user)
)
## Unsafe tar methods #####################################################
# Each of these methods creates a tarfile with a single type of unsafe
# content.
def _create_tar_with_fifo(self):
"""
Tar file with FIFO
"""
fifop = self.unsafe_common_dir / "fifo.file"
fifo_tar = self.unsafe_common_dir / "fifo.tar.gz"
os.mkfifo(fifop)
with tarfile.open(fifo_tar, "w:gz") as tar:
tar.add(fifop)
return fifo_tar
def _create_tar_with_symlink(self):
"""
Tarfile with symlink to path outside directory.
"""
outsidep = self.unsafe_common_dir / "unsafe_file.txt"
symlinkp = self.unsafe_common_dir / "symlink.txt"
symlink_tar = self.unsafe_common_dir / "symlink.tar.gz"
outsidep.symlink(symlinkp) # pylint: disable=no-value-for-parameter
with tarfile.open(symlink_tar, "w:gz") as tar:
tar.add(symlinkp)
return symlink_tar
def _create_tar_file_outside(self, parent=False):
"""
Tarfile that extracts to outside directory.
If parent is False:
The path of the file will match the basename
(`self.unsafe_common_dir`), but then "cd's out".
E.g. "/usr/../etc" == "/etc", but the naive basename of the first
(but not the second) is "/usr"
Extracting this tarfile in directory <dir> will put its contents
directly in <dir> (rather than <dir/tarname>).
"""
outside_tar = self.unsafe_common_dir / "unsafe_file.tar.gz"
tarfile_path = str(
self.unsafe_common_dir / "../a_file" if parent
else self.content_dir / "a_file"
)
with tarfile.open(outside_tar, "w:gz") as tar:
tar.addfile(
tarfile.TarInfo(tarfile_path)
)
return outside_tar
def test_unsafe_tar(self):
"""
Check that safety measure work.
This includes:
'tarbombs' which include files or symlinks with paths
outside or directly in the working directory,
'special files' (character device, block device or FIFOs),
all raise exceptions/400s.
"""
def try_tar(tarpath):
""" Attempt to tar an unacceptable file """
with open(tarpath) as tar:
args = {"name": tarpath, "course-data": [tar]}
resp = self.client.post(self.url, args)
self.assertEquals(resp.status_code, 400)
self.assertTrue("suspicious_operation_message" in resp.content)
try_tar(self._create_tar_with_fifo())
try_tar(self._create_tar_with_symlink())
try_tar(self._create_tar_file_outside())
try_tar(self._create_tar_file_outside(True))
# Check that `ImportStatus` returns the appropriate stage (i.e.,
# either 3, indicating all previous steps are completed, or 0,
# indicating no upload in progress)
resp_status = self.client.get(
course_url(
'course_import_status_handler',
self.course,
filename=os.path.split(self.good_tar)[1]
)
)
import_status = json.loads(resp_status.content)["ImportStatus"]
self.assertIn(import_status, (0, 3))
@override_settings(MODULESTORE_BRANCH='published')
def test_library_import(self):
"""
Try importing a known good library archive, and verify that the
contents of the library have completely replaced the old contents.
"""
# Create some blocks to overwrite
library = LibraryFactory.create(modulestore=self.store)
lib_key = library.location.library_key
test_block = ItemFactory.create(
category="vertical",
parent_location=library.location,
user_id=self.user.id,
publish_item=False,
)
test_block2 = ItemFactory.create(
category="vertical",
parent_location=library.location,
user_id=self.user.id,
publish_item=False
)
# Create a library and blocks that should remain unmolested.
unchanged_lib = LibraryFactory.create()
unchanged_key = unchanged_lib.location.library_key
test_block3 = ItemFactory.create(
category="vertical",
parent_location=unchanged_lib.location,
user_id=self.user.id,
publish_item=False
)
test_block4 = ItemFactory.create(
category="vertical",
parent_location=unchanged_lib.location,
user_id=self.user.id,
publish_item=False
)
# Refresh library.
library = self.store.get_library(lib_key)
children = [self.store.get_item(child).url_name for child in library.children]
self.assertEqual(len(children), 2)
self.assertIn(test_block.url_name, children)
self.assertIn(test_block2.url_name, children)
unchanged_lib = self.store.get_library(unchanged_key)
children = [self.store.get_item(child).url_name for child in unchanged_lib.children]
self.assertEqual(len(children), 2)
self.assertIn(test_block3.url_name, children)
self.assertIn(test_block4.url_name, children)
extract_dir = path(mkdtemp_clean())
tar = tarfile.open(path(TEST_DATA_DIR) / 'imports' / 'library.HhJfPD.tar.gz')
safetar_extractall(tar, extract_dir)
library_items = import_library_from_xml(
self.store, self.user.id,
settings.GITHUB_REPO_ROOT, [extract_dir / 'library'],
load_error_modules=False,
static_content_store=contentstore(),
target_id=lib_key
)
self.assertEqual(lib_key, library_items[0].location.library_key)
library = self.store.get_library(lib_key)
children = [self.store.get_item(child).url_name for child in library.children]
self.assertEqual(len(children), 3)
self.assertNotIn(test_block.url_name, children)
self.assertNotIn(test_block2.url_name, children)
unchanged_lib = self.store.get_library(unchanged_key)
children = [self.store.get_item(child).url_name for child in unchanged_lib.children]
self.assertEqual(len(children), 2)
self.assertIn(test_block3.url_name, children)
self.assertIn(test_block4.url_name, children)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
class ExportTestCase(CourseTestCase):
"""
Tests for export_handler.
"""
def setUp(self):
"""
Sets up the test course.
"""
super(ExportTestCase, self).setUp()
self.url = course_url('course_import_export_handler', self.course)
def test_export_html_unsupported(self):
"""
HTML is unsupported
"""
resp = self.client.get(self.url, HTTP_ACCEPT='text/html')
self.assertEquals(resp.status_code, 406)
def test_export_json_supported(self):
"""
JSON is supported.
"""
resp = self.client.get(self.url, HTTP_ACCEPT='application/json')
self.assertEquals(resp.status_code, 200)
def test_export_targz(self):
"""
Get tar.gz file, using HTTP_ACCEPT.
"""
resp = self.client.get(self.url, HTTP_ACCEPT='application/x-tgz')
self._verify_export_succeeded(resp)
def test_export_targz_urlparam(self):
"""
Get tar.gz file, using URL parameter.
"""
resp = self.client.get(self.url + '?accept=application/x-tgz')
self._verify_export_succeeded(resp)
def _verify_export_succeeded(self, resp):
""" Export success helper method. """
self.assertEquals(resp.status_code, 200)
self.assertTrue(
resp.get('Content-Disposition').startswith('attachment')
)
@override_settings(MODULESTORE_BRANCH='draft-preferred')
def test_export_failure_top_level(self):
"""
Export failure.
"""
fake_xblock = ItemFactory.create(
parent_location=self.course.location,
category='aawefawef'
)
self.store.publish(fake_xblock.location, self.user.id)
self._verify_export_failure(u'{}'.format(self.course.location))
def test_export_failure_subsection_level(self):
"""
Slightly different export failure.
"""
vertical = ItemFactory.create(
parent_location=self.course.location,
category='vertical',
display_name='foo')
ItemFactory.create(
parent_location=vertical.location,
category='aawefawef'
)
self._verify_export_failure(u'{}'.format(vertical.location))
def _verify_export_failure(self, expected_text):
""" Export failure helper method. """
resp = self.client.get(self.url, HTTP_ACCEPT='application/x-tgz')
self.assertEquals(resp.status_code, 200)
self.assertNotIn('Content-Disposition', resp)
self.assertContains(resp, 'Unable to create xml for module')
self.assertContains(resp, expected_text)
def test_library_export(self):
"""
Verify that useable library data can be exported.
"""
youtube_id = "qS4NO9MNC6w"
library = LibraryFactory.create(modulestore=self.store)
video_block = ItemFactory.create(
category="video",
parent_location=library.location,
user_id=self.user.id,
publish_item=False,
youtube_id_1_0=youtube_id
)
name = library.url_name
lib_key = library.location.library_key
root_dir = path(mkdtemp_clean())
export_library_to_xml(self.store, contentstore(), lib_key, root_dir, name)
lib_xml = lxml.etree.XML(open(root_dir / name / LIBRARY_ROOT).read()) # pylint: disable=no-member
self.assertEqual(lib_xml.get('org'), lib_key.org)
self.assertEqual(lib_xml.get('library'), lib_key.library)
block = lib_xml.find('video')
self.assertIsNotNone(block)
self.assertEqual(block.get('url_name'), video_block.url_name)
video_xml = lxml.etree.XML( # pylint: disable=no-member
open(root_dir / name / 'video' / video_block.url_name + '.xml').read()
)
self.assertEqual(video_xml.tag, 'video')
self.assertEqual(video_xml.get('youtube_id_1_0'), youtube_id)
def test_export_success_with_custom_tag(self):
"""
Verify that course export with customtag
"""
xml_string = '<impl>slides</impl>'
vertical = ItemFactory.create(
parent_location=self.course.location, category='vertical', display_name='foo'
)
ItemFactory.create(
parent_location=vertical.location,
category='customtag',
display_name='custom_tag_foo',
data=xml_string
)
self.test_export_targz_urlparam()

View File

@@ -1,100 +0,0 @@
'''
Utilities for contentstore tests
'''
from datetime import timedelta
from django.utils import timezone
from rest_framework.test import APIClient
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.conf import settings
from provider.oauth2.models import AccessToken, Client as OAuth2Client
from provider import constants
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
def create_oauth2_client(user):
"""
Create an OAuth2 client associated with the given user and generate an
access token for said client.
:param user:
:return: a Client (provider.oauth2) and an AccessToken
"""
# Register an OAuth2 Client
client = OAuth2Client(
user=user,
name=user.username,
url="http://127.0.0.1/",
redirect_uri="http://127.0.0.1/",
client_type=constants.CONFIDENTIAL
)
client.save()
# Generate an access token for the client
access_token = AccessToken(
user=user,
client=client,
# Set the access token to expire one day from now
expires=timezone.now() + timedelta(1, 0),
scope=constants.READ_WRITE
)
access_token.save()
return client, access_token
def use_access_token(client, access_token):
"""
Make an APIClient pass an access token for all requests
:param client: an APIClient
:param access_token: an AccessToken
"""
client.credentials(
HTTP_AUTHORIZATION="Bearer {}".format(access_token.token)
)
return client
class CourseTestCase(ModuleStoreTestCase):
"""
Extendable base for test cases dealing with courses
"""
def setUp(self):
"""
These tests need a user in the DB so that the django Test Client can
log them in.
The test user is created in the ModuleStoreTestCase setUp method.
They inherit from the ModuleStoreTestCase class so that the mongodb
collection will be cleared out before each test case execution and
deleted afterwards.
"""
self.user_password = super(CourseTestCase, self).setUp()
# Create an APIClient to simulate requests (like the Django Client, but
# without CSRF)
api_client = APIClient()
# Register an OAuth2 Client
_oauth2_client, access_token = create_oauth2_client(self.user)
self.client = use_access_token(api_client, access_token)
self.course = CourseFactory.create()
def create_non_staff_authed_user_client(self):
"""
Create a non-staff user, log them in (if authenticate=True), and return
the client, user to use for testing.
"""
nonstaff, _password = self.create_non_staff_user()
client = APIClient()
return client, nonstaff

View File

@@ -1,24 +0,0 @@
"""
URLs for course publishing API
"""
from django.conf.urls import patterns, url
from django.conf import settings
from .views import FullCourseImportExport, FullCourseImportStatus
urlpatterns = patterns(
'api.courses.views',
url(
r'^{}$'.format(settings.COURSELIKE_KEY_PATTERN),
FullCourseImportExport.as_view(),
name='course_import_export_handler',
),
url(
r'^{}/import_status/(?P<filename>.+)$'.format(
settings.COURSELIKE_KEY_PATTERN
),
FullCourseImportStatus.as_view(),
name='course_import_status_handler',
),
)

View File

@@ -1,528 +0,0 @@
"""
These views handle all actions in Studio related to import and exporting of
courses
"""
import base64
import logging
from opaque_keys import InvalidKeyError
import os
import re
import shutil
import tarfile
from path import path # pylint: disable=no-name-in-module
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import SuspiciousOperation
from django.core.files.temp import NamedTemporaryFile
from django.core.servers.basehttp import FileWrapper
from django.http import HttpResponse, Http404
from django.utils.translation import ugettext as _
from django.shortcuts import redirect
from rest_framework import renderers
from rest_framework.authentication import OAuth2Authentication, \
SessionAuthentication
from rest_framework.decorators import renderer_classes \
as renderer_classes_decorator
from rest_framework.permissions import IsAuthenticated, BasePermission
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.views import APIView
import dogstats_wrapper as dog_stats_api
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import SerializationError
from xmodule.modulestore.django import modulestore
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryLocator
from xmodule.modulestore.xml_importer import import_course_from_xml, import_library_from_xml
from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml
from xmodule.modulestore import COURSE_ROOT, LIBRARY_ROOT
from student.auth import has_course_author_access
from openedx.core.lib.extract_tar import safetar_extractall
from openedx.core.lib.tempdir import mkdtemp_clean
from util.json_request import JsonResponse
from util.views import ensure_valid_course_key
from urllib import urlencode
log = logging.getLogger(__name__)
# Regex to capture Content-Range header ranges.
CONTENT_RE = re.compile(
r"(?P<start>\d{1,11})-(?P<stop>\d{1,11})/(?P<end>\d{1,11})"
)
class HasCourseWriteAccess(BasePermission):
"""
Permission that checks to see if the request user has permission to access
all course content of the requested course
"""
def has_permission(self, request, view):
course_key_string = view.kwargs['course_key_string']
try:
course_key = CourseKey.from_string(course_key_string)
except InvalidKeyError:
raise Http404
return has_course_author_access(request.user, course_key)
class ArchiveRenderer(renderers.BaseRenderer):
"""
A Renderer for compressed tars. It gets used at the content negotiation
stage, but "render" never actually gets used.
"""
media_type = "application/x-tgz"
format = None
render_style = "binary"
def render(self, data, _media_type=None, _render_context=None):
return data
class FullCourseImportStatus(APIView):
"""
View the import status of a full course import.
"""
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (IsAuthenticated, HasCourseWriteAccess)
@ensure_valid_course_key
def get(self, request, course_key_string, filename=None):
"""
Returns an integer corresponding to the status of a file import.
These are:
-X : Import unsuccessful due to some error with X as stage [0-3]
0 : No status info found (import done or upload still in progress)
1 : Extracting file
2 : Validating.
3 : Importing to mongo
4 : Import successful
"""
status_key = "import_export.import.status:{}|{}{}".format(
request.user.username,
course_key_string,
filename
)
status = cache.get(status_key, 0)
return Response({"ImportStatus": status})
class FullCourseImportExport(APIView):
"""
Import or export a full course archive.
"""
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (IsAuthenticated, HasCourseWriteAccess)
renderer_classes = (ArchiveRenderer, JSONRenderer)
def _save_request_status(self, request, key, status):
"""
Save import status for a course in request session
"""
cache.set(
"import_export.import.status:{}|{}".format(request.user.username, key),
status
)
def _export_error_response(self, params, redirect_url=None):
"""
Reasons about what to do when an export error is encountered. If there
was a redirect URL supplied in the request, pass error information in
the redirect URL. Otherwise, return the information in a JSON response.
"""
if redirect_url:
return redirect("{0}?{1}".format(
redirect_url,
urlencode(params)
))
else:
return JsonResponse(params)
@ensure_valid_course_key
@renderer_classes_decorator((ArchiveRenderer,))
def get(self, request, course_key_string):
"""
The restful handler for exporting a full course or content library.
GET
application/x-tgz: return tar.gz file containing exported course
json: not supported
Note that there are 2 ways to request the tar.gz file. The request
header can specify application/x-tgz via HTTP_ACCEPT, or a query
parameter can be used (?accept=application/x-tgz).
If the tar.gz file has been requested but the export operation fails,
a JSON string will be returned which describes the error
"""
redirect_url = request.QUERY_PARAMS.get('redirect', None)
courselike_key = CourseKey.from_string(course_key_string)
library = isinstance(courselike_key, LibraryLocator)
if library:
courselike_module = modulestore().get_library(courselike_key)
else:
courselike_module = modulestore().get_course(courselike_key)
name = courselike_module.url_name
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
root_dir = path(mkdtemp_clean())
try:
if library:
export_library_to_xml(
modulestore(),
contentstore(),
courselike_key,
root_dir,
name
)
else:
export_course_to_xml(
modulestore(),
contentstore(),
courselike_module.id,
root_dir,
name
)
logging.debug(
u'tar file being generated at %s', export_file.name
)
with tarfile.open(name=export_file.name, mode='w:gz') as tar_file:
tar_file.add(root_dir / name, arcname=name)
except SerializationError as exc:
log.exception(
u'There was an error exporting course %s',
courselike_key
)
unit = None
failed_item = None
parent = None
try:
failed_item = modulestore().get_item(exc.location)
parent_loc = modulestore().get_parent_location(
failed_item.location
)
if parent_loc is not None:
parent = modulestore().get_item(parent_loc)
if parent.location.category == 'vertical':
unit = parent
except Exception: # pylint: disable=broad-except
# if we have a nested exception, then we'll show the more
# generic error message
pass
return self._export_error_response(
{
"context_course": str(courselike_module.location),
"error": True,
"error_message": str(exc),
"failed_module":
str(failed_item.location) if failed_item else None,
"unit":
str(unit.location) if unit else None
},
redirect_url=redirect_url
)
except Exception as exc: # pylint: disable=broad-except
log.exception(
'There was an error exporting course %s',
courselike_key
)
return self._export_error_response(
{
"context_course": courselike_module.url_name,
"error": True,
"error_message": str(exc),
"unit": None
},
redirect_url=redirect_url
)
# The course is all set; return the tar.gz
wrapper = FileWrapper(export_file)
response = HttpResponse(wrapper, content_type='application/x-tgz')
response['Content-Disposition'] = 'attachment; filename={}'.format(
os.path.basename(
export_file.name.encode('utf-8')
)
)
response['Content-Length'] = os.path.getsize(export_file.name)
return response
@ensure_valid_course_key
@renderer_classes_decorator((JSONRenderer,))
def post(self, request, course_key_string):
"""
The restful handler for importing a course.
GET
json: return json import status
POST or PUT
json: import a course via the .tar.gz file specified inrequest.FILES
"""
courselike_key = CourseKey.from_string(course_key_string)
library = isinstance(courselike_key, LibraryLocator)
if library:
root_name = LIBRARY_ROOT
import_func = import_library_from_xml
else:
root_name = COURSE_ROOT
import_func = import_course_from_xml
filename = request.FILES['course-data'].name
courselike_string = unicode(courselike_key) + filename
data_root = path(settings.GITHUB_REPO_ROOT)
subdir = base64.urlsafe_b64encode(repr(courselike_key))
course_dir = data_root / subdir
status_key = "import_export.import.status:{}|{}".format(
request.user.username,
courselike_string
)
# Do everything in a try-except block to make sure everything is
# properly cleaned up.
try:
# Cache the import progress
self._save_request_status(request, courselike_string, 0)
if not filename.endswith('.tar.gz'):
self._save_request_status(request, courselike_string, -1)
return JsonResponse(
{
'error_message': _(
'We only support uploading a .tar.gz file.'
),
'stage': -1
},
status=415
)
temp_filepath = course_dir / filename
# Only handle exceptions caused by the directory already existing,
# to avoid a potential race condition caused by the "check and go"
# method.
try:
os.makedirs(course_dir)
except OSError as exc:
if exc.errno != exc.EEXIST:
raise
logging.debug('importing course to %s', temp_filepath)
# Get upload chunks byte ranges
try:
matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"])
content_range = matches.groupdict()
except KeyError: # Single chunk
# no Content-Range header, so make one that will work
content_range = {'start': 0, 'stop': 1, 'end': 2}
# stream out the uploaded files in chunks to disk
if int(content_range['start']) == 0:
mode = "wb+"
else:
mode = "ab+"
size = os.path.getsize(temp_filepath)
# Check to make sure we haven't missed a chunk
# This shouldn't happen, even if different instances are
# handling the same session, but it's always better to catch
# errors earlier.
if size < int(content_range['start']):
self._save_request_status(request, courselike_string, -1)
log.warning(
"Reported range %s does not match size downloaded so "
"far %s",
content_range['start'],
size
)
return JsonResponse(
{
'error_message': _(
'File upload corrupted. Please try again'
),
'stage': -1
},
status=409
)
# The last request sometimes comes twice. This happens because
# nginx sends a 499 error code when the response takes too long.
elif size > int(content_range['stop']) \
and size == int(content_range['end']):
return JsonResponse({'ImportStatus': 1})
with open(temp_filepath, mode) as temp_file:
for chunk in request.FILES['course-data'].chunks():
temp_file.write(chunk)
size = os.path.getsize(temp_filepath)
if int(content_range['stop']) != int(content_range['end']) - 1:
# More chunks coming
return JsonResponse({
"files": [{
"name": filename,
"size": size,
"delete_url": "",
"delete_type": "",
"thumbnail_url": ""
}]
})
# Send errors to client with stage at which error occurred.
except Exception as exception: # pylint: disable=broad-except
self._save_request_status(request, courselike_string, -1)
if course_dir.isdir(): # pylint: disable=no-value-for-parameter
shutil.rmtree(course_dir)
log.info(
"Course import %s: Temp data cleared", courselike_key
)
log.exception("error importing course")
return JsonResponse(
{
'error_message': str(exception),
'stage': -1
},
status=400
)
# try-finally block for proper clean up after receiving last chunk.
try:
# This was the last chunk.
log.info("Course import %s: Upload complete", courselike_key)
self._save_request_status(request, courselike_string, 1)
tar_file = tarfile.open(temp_filepath)
try:
safetar_extractall(
tar_file,
(course_dir + '/').encode('utf-8'))
except SuspiciousOperation as exc:
self._save_request_status(request, courselike_string, -1)
return JsonResponse(
{
'error_message': 'Unsafe tar file. Aborting import.',
'suspicious_operation_message': exc.args[0],
'stage': -1
},
status=400
)
finally:
tar_file.close()
log.info(
"Course import %s: Uploaded file extracted", courselike_key
)
self._save_request_status(request, courselike_string, 2)
# find the 'course.xml' file
def get_all_files(directory):
"""
For each file in the directory, yield a 2-tuple of (file-name,
directory-path)
"""
for dirpath, _dirnames, filenames in os.walk(directory):
for filename in filenames:
yield (filename, dirpath)
def get_dir_for_fname(directory, filename):
"""
Returns the dirpath for the first file found in the directory
with the given name. If there is no file in the directory with
the specified name, return None.
"""
for fname, dirpath in get_all_files(directory):
if fname == filename:
return dirpath
return None
dirpath = get_dir_for_fname(course_dir, root_name)
if not dirpath:
self._save_request_status(request, courselike_string, -2)
return JsonResponse(
{
'error_message': _(
'Could not find the {root_xml_file} file in the package.'
).format(root_xml_file=root_name),
'stage': -2
},
status=415
)
dirpath = os.path.relpath(dirpath, data_root)
logging.debug('found %s at %s', root_name, dirpath)
log.info(
"Course import %s: Extracted file verified",
courselike_key
)
self._save_request_status(request, courselike_string, 3)
with dog_stats_api.timer(
'courselike_import.time',
tags=[u"courselike:{}".format(courselike_key)]
):
courselike_items = import_func(
modulestore(),
request.user.id,
settings.GITHUB_REPO_ROOT,
[dirpath],
load_error_modules=False,
static_content_store=contentstore(),
target_id=courselike_key,
)
new_location = courselike_items[0].location
logging.debug('new course at %s', new_location)
log.info(
"Course import %s: Course import successful", courselike_key
)
self._save_request_status(request, courselike_string, 4)
# Send errors to client with stage at which error occurred.
except Exception as exception: # pylint: disable=broad-except
log.exception(
"error importing course"
)
return JsonResponse(
{
'error_message': str(exception),
'stage': -cache.get(status_key)
},
status=400
)
finally:
if course_dir.isdir(): # pylint: disable=no-value-for-parameter
shutil.rmtree(course_dir)
log.info(
"Course import %s: Temp data cleared", courselike_key # pylint: disable=no-value-for-parameter
)
# set failed stage number with negative sign in case of an
# unsuccessful import
if cache.get(status_key) != 4:
self._save_request_status(
request,
courselike_string,
-abs(cache.get(status_key))
)
return JsonResponse({'status': 'OK'})

View File

@@ -1,3 +0,0 @@
"""
A models.py is required to make this an app (until we move to Django 1.7)
"""

View File

@@ -1,13 +0,0 @@
"""
URLs for the public API
"""
from django.conf.urls import patterns, url, include
urlpatterns = patterns(
'',
# Import/Export API
url(
r'^courses/',
include('openedx.core.djangoapps.import_export.courses.urls')
),
)