Commit bd8661a8 by Don Mitchell

Merge pull request #1211 from MITx/feature/cdodge/export

Feature/cdodge/export
parents a0fa8c98 507a1dc0
###
### Script for exporting courseware from Mongo to a tar.gz file
###
import os
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
unnamed_modules = 0
class Command(BaseCommand):
help = \
'''Import the specified data directory into the default ModuleStore'''
def handle(self, *args, **options):
if len(args) != 2:
raise CommandError("import requires two arguments: <course location> <output path>")
course_id = args[0]
output_path = args[1]
print "Exporting course id = {0} to {1}".format(course_id, output_path)
location = CourseDescriptor.id_to_location(course_id)
root_dir = os.path.dirname(output_path)
course_dir = os.path.splitext(os.path.basename(output_path))[0]
export_to_xml(modulestore('direct'), contentstore(), location, root_dir, course_dir)
import json import json
import shutil
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from override_settings import override_settings from override_settings import override_settings
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from path import path from path import path
from tempfile import mkdtemp
from student.models import Registration from student.models import Registration
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -18,6 +20,7 @@ from xmodule.modulestore.store_utilities import delete_course ...@@ -18,6 +20,7 @@ from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.xml_exporter import export_to_xml
def parse_json(response): def parse_json(response):
"""Parse response, which is assumed to be json""" """Parse response, which is assumed to be json"""
...@@ -385,4 +388,35 @@ class ContentStoreTest(TestCase): ...@@ -385,4 +388,35 @@ class ContentStoreTest(TestCase):
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None])) items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
self.assertEqual(len(items), 0) self.assertEqual(len(items), 0)
def test_export_course(self):
ms = modulestore('direct')
cs = contentstore()
import_from_xml(ms, 'common/test/data/', ['full'])
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
root_dir = path(mkdtemp())
print 'Exporting to tempdir = {0}'.format(root_dir)
# export out to a tempdir
export_to_xml(ms, cs, location, root_dir, 'test_export')
# remove old course
delete_course(ms, cs, location)
# reimport
import_from_xml(ms, root_dir, ['test_export'])
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
self.assertGreater(len(items), 0)
for descriptor in items:
print "Checking {0}....".format(descriptor.location.url())
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
self.assertEqual(resp.status_code, 200)
shutil.rmtree(root_dir)
...@@ -10,6 +10,10 @@ from datetime import datetime ...@@ -10,6 +10,10 @@ from datetime import datetime
from collections import defaultdict from collections import defaultdict
from uuid import uuid4 from uuid import uuid4
from path import path from path import path
from xmodule.modulestore.xml_exporter import export_to_xml
from tempfile import mkdtemp
from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image from PIL import Image
...@@ -1302,3 +1306,55 @@ def import_course(request, org, course, name): ...@@ -1302,3 +1306,55 @@ def import_course(request, org, course, name):
course_module.location.course, course_module.location.course,
course_module.location.name]) course_module.location.name])
}) })
@ensure_csrf_cookie
@login_required
def generate_export_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
course_module = modulestore().get_item(location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
loc = Location(location)
export_file = NamedTemporaryFile(prefix=name+'.', suffix=".tar.gz")
root_dir = path(mkdtemp())
# export out to a tempdir
logging.debug('root = {0}'.format(root_dir))
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name)
#filename = root_dir / name + '.tar.gz'
logging.debug('tar file being generated at {0}'.format(export_file.name))
tf = tarfile.open(name=export_file.name, mode='w:gz')
tf.add(root_dir/name, arcname=name)
tf.close()
# remove temp dir
shutil.rmtree(root_dir/name)
wrapper = FileWrapper(export_file)
response = HttpResponse(wrapper, content_type='application/x-tgz')
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name)
response['Content-Length'] = os.path.getsize(export_file.name)
return response
@ensure_csrf_cookie
@login_required
def export_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
course_module = modulestore().get_item(location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
return render_to_response('export.html', {
'context_course': course_module,
'active_tab': 'export',
'successful_import_redirect_url' : ''
})
.export {
.export-overview {
@extend .window;
@include clearfix;
padding: 30px 40px;
}
.description {
float: left;
width: 62%;
margin-right: 3%;
font-size: 14px;
h2 {
font-weight: 700;
font-size: 19px;
margin-bottom: 20px;
}
strong {
font-weight: 700;
}
p + p {
margin-top: 20px;
}
ul {
margin: 20px 0;
list-style: disc inside;
li {
margin: 0 0 5px 0;
}
}
}
.export-form-wrapper {
.export-form {
float: left;
width: 35%;
padding: 25px 30px 35px;
@include box-sizing(border-box);
border: 1px solid $mediumGrey;
border-radius: 3px;
background: $lightGrey;
text-align: center;
h2 {
margin-bottom: 30px;
font-size: 26px;
font-weight: 300;
}
.error-block {
display: none;
margin-bottom: 15px;
font-size: 13px;
}
.error-block {
color: $error-red;
}
.button-export {
@include green-button;
padding: 10px 50px 11px;
font-size: 17px;
}
.message-status {
margin-top: 10px;
font-size: 12px;
}
.progress-bar {
display: none;
width: 350px;
height: 30px;
margin: 30px auto 10px;
border: 1px solid $blue;
&.loaded {
border-color: #66b93d;
.progress-fill {
background: #66b93d;
}
}
}
.progress-fill {
width: 0%;
height: 30px;
background: $blue;
color: #fff;
line-height: 48px;
}
}
// downloading state
&.is-downloading {
.progress-bar {
display: block;
}
.button-export {
padding: 10px 50px 11px;
font-size: 17px;
&.disabled {
pointer-events: none;
cursor: default;
}
}
}
}
}
\ No newline at end of file
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
@import "static-pages"; @import "static-pages";
@import "users"; @import "users";
@import "import"; @import "import";
@import "export";
@import "settings"; @import "settings";
@import "course-info"; @import "course-info";
@import "landing"; @import "landing";
......
<%inherit file="base.html" />
<%namespace name='static' file='static_content.html'/>
<%! from django.core.urlresolvers import reverse %>
<%block name="title">Export</%block>
<%block name="bodyclass">export</%block>
<%block name="content">
<div class="main-wrapper">
<div class="inner-wrapper">
<article class="export-overview">
<div class="description">
<h2>About Exporting Courses</h2>
<p>When exporting your course, you will receive a .tar.gz formatted file that contains the following course data:</p>
<ul>
<li>Course Structure (Sections and sub-section ordering)</li>
<li>Individual Units</li>
<li>Individual Problems</li>
<li>Static Pages</li>
<li>Course Assets</li>
</ul>
<p>Your course export <strong>will not include</strong>: student data, forum/discussion data, course settings, certificates, grading information, or user data.</p>
</div>
<!-- default state -->
<div class="export-form-wrapper">
<form action="${reverse('generate_export_course', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" method="post" enctype="multipart/form-data" class="export-form">
<h2>Export Course:</h2>
<p class="error-block"></p>
<a href="${reverse('generate_export_course', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" class="button-export">Download Files</a>
</form>
</div>
<!-- download state: after user clicks download buttons -->
<%doc>
<div class="export-form-wrapper is-downloading">
<form action="${reverse('export_course', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" method="post" enctype="multipart/form-data" class="export-form">
<h2>Export Course:</h2>
<p class="error-block"></p>
<a href="#" class="button-export disabled">Files Downloading</a>
<p class="message-status">Download not start? <a href="#" class="text-export">Try again</a></p>
</form>
</div>
</%doc>
</article>
</div>
</div>
</%block>
<%block name="jsextra">
<script>
(function() {
var bar = $('.progress-bar');
var fill = $('.progress-fill');
var percent = $('.percent');
var status = $('#status');
var submitBtn = $('.submit-button');
$('form').ajaxForm({
beforeSend: function() {
status.empty();
var percentVal = '0%';
bar.show();
fill.width(percentVal);
percent.html(percentVal);
submitBtn.hide();
},
uploadProgress: function(event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
fill.width(percentVal);
percent.html(percentVal);
},
complete: function(xhr) {
if (xhr.status == 200) {
alert('Your import was successful.');
window.location = '${successful_import_redirect_url}';
}
else
alert('Your import has failed.\n\n' + xhr.responseText);
submitBtn.show();
bar.hide();
}
});
})();
</script>
</%block>
\ No newline at end of file
...@@ -33,6 +33,7 @@ ...@@ -33,6 +33,7 @@
<li><a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='assets-tab'>Assets</a></li> <li><a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='assets-tab'>Assets</a></li>
<li><a href="${reverse('course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='settings-tab'>Settings</a></li> <li><a href="${reverse('course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='settings-tab'>Settings</a></li>
<li><a href="${reverse('import_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='import-tab'>Import</a></li> <li><a href="${reverse('import_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='import-tab'>Import</a></li>
<li><a href="${reverse('export_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='export-tab'>Export</a></li>
</ul> </ul>
% endif % endif
</nav> </nav>
......
...@@ -23,6 +23,11 @@ urlpatterns = ('', ...@@ -23,6 +23,11 @@ urlpatterns = ('',
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/import/(?P<name>[^/]+)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/import/(?P<name>[^/]+)$',
'contentstore.views.import_course', name='import_course'), 'contentstore.views.import_course', name='import_course'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/export/(?P<name>[^/]+)$',
'contentstore.views.export_course', name='export_course'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/generate_export/(?P<name>[^/]+)$',
'contentstore.views.generate_export_course', name='generate_export_course'),
url(r'^preview/modx/(?P<preview_id>[^/]*)/(?P<location>.*?)/(?P<dispatch>[^/]*)$', url(r'^preview/modx/(?P<preview_id>[^/]*)/(?P<location>.*?)/(?P<dispatch>[^/]*)$',
'contentstore.views.preview_dispatch', name='preview_dispatch'), 'contentstore.views.preview_dispatch', name='preview_dispatch'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$',
......
...@@ -12,13 +12,16 @@ from .django import contentstore ...@@ -12,13 +12,16 @@ from .django import contentstore
from PIL import Image from PIL import Image
class StaticContent(object): class StaticContent(object):
def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None): def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None):
self.location = loc self.location = loc
self.name = name #a display string which can be edited, and thus not part of the location which needs to be fixed self.name = name #a display string which can be edited, and thus not part of the location which needs to be fixed
self.content_type = content_type self.content_type = content_type
self.data = data self.data = data
self.last_modified_at = last_modified_at self.last_modified_at = last_modified_at
self.thumbnail_location = Location(thumbnail_location) self.thumbnail_location = Location(thumbnail_location)
# optional information about where this file was imported from. This is needed to support import/export
# cycles
self.import_path = import_path
@property @property
def is_thumbnail(self): def is_thumbnail(self):
......
...@@ -11,6 +11,8 @@ import logging ...@@ -11,6 +11,8 @@ import logging
from .content import StaticContent, ContentStore from .content import StaticContent, ContentStore
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from fs.osfs import OSFS
import os
class MongoContentStore(ContentStore): class MongoContentStore(ContentStore):
...@@ -32,7 +34,7 @@ class MongoContentStore(ContentStore): ...@@ -32,7 +34,7 @@ class MongoContentStore(ContentStore):
self.delete(id) self.delete(id)
with self.fs.new_file(_id = id, filename=content.get_url_path(), content_type=content.content_type, with self.fs.new_file(_id = id, filename=content.get_url_path(), content_type=content.content_type,
displayname=content.name, thumbnail_location=content.thumbnail_location) as fp: displayname=content.name, thumbnail_location=content.thumbnail_location, import_path=content.import_path) as fp:
fp.write(content.data) fp.write(content.data)
...@@ -47,10 +49,32 @@ class MongoContentStore(ContentStore): ...@@ -47,10 +49,32 @@ class MongoContentStore(ContentStore):
try: try:
with self.fs.get(id) as fp: with self.fs.get(id) as fp:
return StaticContent(location, fp.displayname, fp.content_type, fp.read(), return StaticContent(location, fp.displayname, fp.content_type, fp.read(),
fp.uploadDate, thumbnail_location = fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None) fp.uploadDate, thumbnail_location = fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
import_path = fp.import_path if hasattr(fp, 'import_path') else None)
except NoFile: except NoFile:
raise NotFoundError() raise NotFoundError()
def export(self, location, output_directory):
content = self.find(location)
if content.import_path is not None:
output_directory = output_directory + '/' + os.path.dirname(content.import_path)
if not os.path.exists(output_directory):
os.makedirs(output_directory)
disk_fs = OSFS(output_directory)
with disk_fs.open(content.name, 'wb') as asset_file:
asset_file.write(content.data)
def export_all_for_course(self, course_location, output_directory):
assets = self.get_all_content_for_course(course_location)
for asset in assets:
asset_location = Location(asset['_id'])
self.export(asset_location, output_directory)
def get_all_content_thumbnails_for_course(self, location): def get_all_content_thumbnails_for_course(self, location):
return self._get_all_content_for_course(location, get_thumbnails = True) return self._get_all_content_for_course(location, get_thumbnails = True)
......
...@@ -160,7 +160,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): ...@@ -160,7 +160,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
filepath = u'{category}/{pathname}.html'.format(category=self.category, filepath = u'{category}/{pathname}.html'.format(category=self.category,
pathname=pathname) pathname=pathname)
resource_fs.makedir(os.path.dirname(filepath), allow_recreate=True) resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
with resource_fs.open(filepath, 'w') as file: with resource_fs.open(filepath, 'w') as file:
file.write(self.definition['data']) file.write(self.definition['data'])
......
import logging
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from fs.osfs import OSFS
def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir):
course = modulestore.get_item(course_location)
fs = OSFS(root_dir)
export_fs = fs.makeopendir(course_dir)
xml = course.export_to_xml(export_fs)
with export_fs.open('course.xml', 'w') as course_xml:
course_xml.write(xml)
# export the static assets
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
\ No newline at end of file
...@@ -32,7 +32,7 @@ def import_static_content(modules, course_loc, course_data_path, static_content_ ...@@ -32,7 +32,7 @@ def import_static_content(modules, course_loc, course_data_path, static_content_
with open(content_path, 'rb') as f: with open(content_path, 'rb') as f:
data = f.read() data = f.read()
content = StaticContent(content_loc, filename, mime_type, data) content = StaticContent(content_loc, filename, mime_type, data, import_path = fullname_with_subpath)
# first let's save a thumbnail so we can get back a thumbnail location # first let's save a thumbnail so we can get back a thumbnail location
thumbnail_content = static_content_store.generate_thumbnail(content) thumbnail_content = static_content_store.generate_thumbnail(content)
...@@ -66,7 +66,7 @@ def verify_content_links(module, base_dir, static_content_store, link, remap_dic ...@@ -66,7 +66,7 @@ def verify_content_links(module, base_dir, static_content_store, link, remap_dic
with open(static_pathname, 'rb') as f: with open(static_pathname, 'rb') as f:
data = f.read() data = f.read()
content = StaticContent(content_loc, filename, mime_type, data) content = StaticContent(content_loc, filename, mime_type, data, import_path = path)
# first let's save a thumbnail so we can get back a thumbnail location # first let's save a thumbnail so we can get back a thumbnail location
thumbnail_content = static_content_store.generate_thumbnail(content) thumbnail_content = static_content_store.generate_thumbnail(content)
......
...@@ -98,20 +98,30 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -98,20 +98,30 @@ class XmlDescriptor(XModuleDescriptor):
metadata_to_strip = ('data_dir', metadata_to_strip = ('data_dir',
# cdodge: @TODO: We need to figure out a way to export out 'tabs' and 'grading_policy' which is on the course # cdodge: @TODO: We need to figure out a way to export out 'tabs' and 'grading_policy' which is on the course
'tabs', 'grading_policy', 'tabs', 'grading_policy', 'is_draft', 'published_by', 'published_date',
'discussion_blackouts',
# VS[compat] -- remove the below attrs once everything is in the CMS # VS[compat] -- remove the below attrs once everything is in the CMS
'course', 'org', 'url_name', 'filename') 'course', 'org', 'url_name', 'filename')
metadata_to_export_to_policy = ('discussion_topics')
# A dictionary mapping xml attribute names AttrMaps that describe how # A dictionary mapping xml attribute names AttrMaps that describe how
# to import and export them # to import and export them
# Allow json to specify either the string "true", or the bool True. The string is preferred. # Allow json to specify either the string "true", or the bool True. The string is preferred.
to_bool = lambda val: val == 'true' or val == True to_bool = lambda val: val == 'true' or val == True
from_bool = lambda val: str(val).lower() from_bool = lambda val: str(val).lower()
bool_map = AttrMap(to_bool, from_bool) bool_map = AttrMap(to_bool, from_bool)
to_int = lambda val: int(val)
from_int = lambda val: str(val)
int_map = AttrMap(to_int, from_int)
xml_attribute_map = { xml_attribute_map = {
# type conversion: want True/False in python, "true"/"false" in xml # type conversion: want True/False in python, "true"/"false" in xml
'graded': bool_map, 'graded': bool_map,
'hide_progress_tab': bool_map, 'hide_progress_tab': bool_map,
'allow_anonymous': bool_map,
'allow_anonymous_to_peers': bool_map,
'weight':int_map
} }
...@@ -359,8 +369,9 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -359,8 +369,9 @@ class XmlDescriptor(XModuleDescriptor):
# Add the non-inherited metadata # Add the non-inherited metadata
for attr in sorted(self.own_metadata): for attr in sorted(self.own_metadata):
# don't want e.g. data_dir # don't want e.g. data_dir
if attr not in self.metadata_to_strip: if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy:
val = val_for_xml(attr) val = val_for_xml(attr)
#logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr))
xml_object.set(attr, val) xml_object.set(attr, val)
if self.export_to_file(): if self.export_to_file():
......
...@@ -148,7 +148,7 @@ def get_course_about_section(course, section_key): ...@@ -148,7 +148,7 @@ def get_course_about_section(course, section_key):
request = get_request_for_thread() request = get_request_for_thread()
loc = course.location._replace(category='about', name=section_key) loc = course.location._replace(category='about', name=section_key)
course_module = get_module(request.user, request, loc, None, course.id, not_found_ok = True, wrap_xmodule_display = False) course_module = get_module(request.user, request, loc, None, course.id, not_found_ok = True, wrap_xmodule_display = True)
html = '' html = ''
...@@ -186,7 +186,7 @@ def get_course_info_section(request, cache, course, section_key): ...@@ -186,7 +186,7 @@ def get_course_info_section(request, cache, course, section_key):
loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key) loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key)
course_module = get_module(request.user, request, loc, cache, course.id, wrap_xmodule_display = False) course_module = get_module(request.user, request, loc, cache, course.id, wrap_xmodule_display = True)
html = '' html = ''
if course_module is not None: if course_module is not None:
......
...@@ -445,6 +445,18 @@ namespace :cms do ...@@ -445,6 +445,18 @@ namespace :cms do
end end
end end
namespace :cms do
desc "Export course data to a tar.gz file"
task :export do
if ENV['COURSE_ID'] and ENV['OUTPUT_PATH']
sh(django_admin(:cms, :dev, :export, ENV['COURSE_ID'], ENV['OUTPUT_PATH']))
else
raise "Please specify a COURSE_ID and OUTPUT_PATH.\n" +
"Example: \`rake cms:export COURSE_ID=MITx/12345/name OUTPUT_PATH=foo.tar.gz\`"
end
end
end
desc "Build a properties file used to trigger autodeploy builds" desc "Build a properties file used to trigger autodeploy builds"
task :autodeploy_properties do task :autodeploy_properties do
File.open("autodeploy.properties", "w") do |file| File.open("autodeploy.properties", "w") do |file|
......
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