diff --git a/exporter/api.py b/exporter/api.py index 2d784156..f1293e45 100644 --- a/exporter/api.py +++ b/exporter/api.py @@ -4,11 +4,11 @@ from __future__ import unicode_literals -from tempfile import mkdtemp, mkstemp -import tarfile import os -from shutil import rmtree import re +import tarfile +from shutil import rmtree +from tempfile import mkdtemp, mkstemp from django.utils.text import slugify from django.core.files.storage import default_storage diff --git a/rest/tests/base.py b/rest/tests/base.py index 86287218..ac2fcc17 100644 --- a/rest/tests/base.py +++ b/rest/tests/base.py @@ -759,7 +759,7 @@ def get_learning_resource_export_task(self, repo_slug, task_id, if expected_status == HTTP_200_OK: return as_json(resp) - def create_learning_resource_export_task(self, repo_slug, + def create_learning_resource_export_task(self, repo_slug, input_dict, expected_status=HTTP_201_CREATED): """ Helper method to create a task for the user to export a tarball of @@ -769,7 +769,9 @@ def create_learning_resource_export_task(self, repo_slug, '/api/v1/repositories/{repo_slug}/' 'learning_resource_export_tasks/'.format( repo_slug=repo_slug, - ) + ), + json.dumps(input_dict), + content_type='application/json', ) self.assertEqual(expected_status, resp.status_code) if expected_status == HTTP_201_CREATED: diff --git a/rest/tests/test_export.py b/rest/tests/test_export.py index e6526e32..c7828d4a 100644 --- a/rest/tests/test_export.py +++ b/rest/tests/test_export.py @@ -6,7 +6,9 @@ from tempfile import mkdtemp from shutil import rmtree import tarfile +import os +from django.utils.text import slugify from django.test import override_settings from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND from six import BytesIO @@ -28,6 +30,7 @@ class TestExport(RESTTestCase): ) def test_create_new_task(self): """Test a basic export.""" + self.import_course_tarball(self.repo) resources = LearningResource.objects.filter( course__repository__id=self.repo.id).all() for resource in resources: @@ -35,8 +38,11 @@ def test_create_new_task(self): "id": resource.id }) + # Skip first one to test that it's excluded from export. task_id = self.create_learning_resource_export_task( - self.repo.slug)['id'] + self.repo.slug, + {"ids": [r.id for r in resources[1:]]} + )['id'] result = self.get_learning_resource_export_tasks( self.repo.slug)['results'][0] @@ -61,11 +67,23 @@ def test_create_new_task(self): self.assertEqual(HTTP_200_OK, resp.status_code) tempdir = mkdtemp() + + def make_path(resource): + """Create a path that should exist for a resource.""" + type_name = resource.learning_resource_type.name + return os.path.join( + tempdir, type_name, "{id}_{url_name}.xml".format( + id=resource.id, + url_name=slugify(resource.url_name)[:200], + ) + ) try: fakefile = BytesIO(b"".join(resp.streaming_content)) with tarfile.open(fileobj=fakefile, mode="r:gz") as tar: tar.extractall(path=tempdir) - assert_resource_directory(self, resources, tempdir) + self.assertFalse(os.path.isfile(make_path(resources[0]))) + assert_resource_directory(self, resources[1:], tempdir) + finally: rmtree(tempdir) diff --git a/rest/views.py b/rest/views.py index 0406428b..cf8c618a 100644 --- a/rest/views.py +++ b/rest/views.py @@ -678,9 +678,16 @@ def post(self, request, *args, **kwargs): repo_slug = self.kwargs['repo_slug'] try: - exports = self.request.session[EXPORTS_KEY][repo_slug] + exports = set(self.request.session[EXPORTS_KEY][repo_slug]) except KeyError: - exports = [] + exports = set() + + ids = self.request.data['ids'] + for resource_id in ids: + if resource_id not in exports: + raise ValidationError("id {id} is not in export list".format( + id=resource_id + )) # Cancel any old tasks. old_tasks = self.request.session.get( @@ -694,7 +701,7 @@ def post(self, request, *args, **kwargs): self.request.session[EXPORT_TASK_KEY][repo_slug] = {} learning_resources = LearningResource.objects.filter( - id__in=exports).all() + id__in=ids).all() result = export_resources.delay( learning_resources, self.request.user.username) diff --git a/ui/jstests/test_lr_exports.jsx b/ui/jstests/test_lr_exports.jsx index f7ff0594..f194c959 100644 --- a/ui/jstests/test_lr_exports.jsx +++ b/ui/jstests/test_lr_exports.jsx @@ -58,7 +58,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', { "id": "task1", "status": "successful", - "url": "/media/resource_exports/export_task1.tar.gz" + "url": "/media/resource_exports/export_task1.tar.gz", + "collision": false } ] } @@ -76,7 +77,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', responseText: { "id": "task2", "status": "processing", - "url": "" + "url": "", + "collision": false } }); TestUtils.initMockjax({ @@ -107,7 +109,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exportButtonVisible: false, - exports: [] + exports: [], + exportsSelected: [], + collision: false }, component.state ); @@ -118,7 +122,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: true + exportButtonVisible: true, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -133,7 +139,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: false + exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -145,7 +153,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', responseText: { "id": "task2", "status": "processing", - "url": "" + "url": "", + "collision": false } }); @@ -154,7 +163,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exportButtonVisible: false, - exports: [learningResourceResponse] + exports: [learningResourceResponse], + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -166,7 +177,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', responseText: { "id": "task2", "status": "success", - "url": "/media/resource_exports/export_task2.tar.gz" + "url": "/media/resource_exports/export_task2.tar.gz", + "collision": false } }); @@ -179,6 +191,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', { exports: [], exportButtonVisible: false, + exportsSelected: [], + collision: false, url: "/media/resource_exports/export_task2.tar.gz" }, component.state @@ -221,7 +235,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exportButtonVisible: false, - exports: [] + exports: [], + exportsSelected: [], + collision: false }, component.state ); @@ -231,7 +247,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [], - exportButtonVisible: false + exportButtonVisible: false, + exportsSelected: [], + collision: false }, component.state ); @@ -257,7 +275,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exportButtonVisible: false, - exports: [] + exports: [], + exportsSelected: [], + collision: false }, component.state ); @@ -268,7 +288,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: true + exportButtonVisible: true, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -291,6 +313,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', { exports: [learningResourceResponse], exportButtonVisible: true, + exportsSelected: [learningResourceResponse.id], + collision: false, message: { error: "Error preparing learning resources for download." } @@ -321,7 +345,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exportButtonVisible: false, - exports: [] + exports: [], + exportsSelected: [], + collision: false }, component.state ); @@ -332,7 +358,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: true + exportButtonVisible: true, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -355,6 +383,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', { exports: [learningResourceResponse], exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], + collision: false, message: { error: "Error occurred preparing " + "learning resources for download." @@ -387,7 +417,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exportButtonVisible: false, - exports: [] + exports: [], + exportsSelected: [], + collision: false }, component.state ); @@ -398,7 +430,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: true + exportButtonVisible: true, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -413,7 +447,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: false + exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -432,6 +468,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', { exports: [learningResourceResponse], exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], + collision: false, message: { error: "Error occurred preparing learning " + "resources for download." @@ -465,7 +503,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exportButtonVisible: false, - exports: [] + exports: [], + exportsSelected: [], + collision: false }, component.state ); @@ -476,7 +516,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: true + exportButtonVisible: true, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -491,7 +533,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: false + exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -503,7 +547,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', responseText: { "id": "task2", "status": "processing", - "url": "" + "url": "", + "collision": false } }); @@ -513,7 +558,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exportButtonVisible: false, - exports: [learningResourceResponse] + exports: [learningResourceResponse], + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -522,7 +569,12 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', url: '/api/v1/repositories/repo/' + 'learning_resource_export_tasks/task2/', type: 'GET', - status: 400 + responseText: { + "id": "task2", + "status": "failure", + "url": "", + "collision": false + } }); waitForAjax(1, function() { @@ -530,6 +582,117 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', { exports: [learningResourceResponse], exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], + message: { + error: "Error occurred preparing learning " + + "resources for download." + }, + collision: false + }, + component.state + ); + done(); + }); + }); + }); + }); + }; + + React.addons.TestUtils.renderIntoDocument( + ); + }); + + QUnit.test("Test failure to get status update due to 500", + function(assert) { + var done = assert.async(); + + var afterMount = function(component) { + // Initial state + assert.deepEqual( + { + exportButtonVisible: false, + exports: [], + exportsSelected: [], + collision: false + }, + component.state + ); + + // Wait for GET exports and GET related learning resources. + waitForAjax(2, function() { + // one export + assert.deepEqual( + { + exports: [learningResourceResponse], + exportButtonVisible: true, + exportsSelected: [learningResourceResponse.id], + collision: false + }, + component.state + ); + + var $node = $(React.findDOMNode(component)); + var button = $node.find("button")[0]; + + // Click the button. There should be a POST to start the task + // followed by an immediate GET to check status (which is 'processing'). + React.addons.TestUtils.Simulate.click(button); + waitForAjax(2, function() { + assert.deepEqual( + { + exports: [learningResourceResponse], + exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], + collision: false + }, + component.state + ); + + TestUtils.replaceMockjax({ + url: '/api/v1/repositories/repo/' + + 'learning_resource_export_tasks/task2/', + type: 'GET', + responseText: { + "id": "task2", + "status": "processing", + "url": "", + "collision": false + } + }); + + // In a second another GET for status will happen which will + // fail. + waitForAjax(1, function() { + assert.deepEqual( + { + exportButtonVisible: false, + exports: [learningResourceResponse], + exportsSelected: [learningResourceResponse.id], + collision: false + }, + component.state + ); + + TestUtils.replaceMockjax({ + url: '/api/v1/repositories/repo/' + + 'learning_resource_export_tasks/task2/', + type: 'GET', + status: 500 + }); + + waitForAjax(1, function() { + assert.deepEqual( + { + exports: [learningResourceResponse], + exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], + collision: false, message: { error: "Error occurred preparing learning " + "resources for download." @@ -567,7 +730,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exportButtonVisible: false, - exports: [] + exports: [], + exportsSelected: [], + collision: false, }, component.state ); @@ -578,7 +743,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: true + exportButtonVisible: true, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -593,7 +760,9 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', assert.deepEqual( { exports: [learningResourceResponse], - exportButtonVisible: false + exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], + collision: false }, component.state ); @@ -605,7 +774,8 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', responseText: { "id": "task2", "status": "success", - "url": "/media/resource_exports/export_task2.tar.gz" + "url": "/media/resource_exports/export_task2.tar.gz", + "collision": true } }); TestUtils.replaceMockjax({ @@ -622,10 +792,12 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', { exports: [learningResourceResponse], exportButtonVisible: false, + exportsSelected: [learningResourceResponse.id], message: { error: "Error clearing learning resource exports." }, - url: "/media/resource_exports/export_task2.tar.gz" + url: "/media/resource_exports/export_task2.tar.gz", + collision: true }, component.state ); @@ -654,4 +826,155 @@ define(['QUnit', 'jquery', 'lr_exports', 'reactaddons', Exports.loader("repo", "user", function() {}, container); assert.equal(1, $(container).find("div").size()); }); + + QUnit.test("Assert that export checkboxes adjust ids sent to server", + function(assert) { + var done = assert.async(); + + var clearExportCount = 0; + var clearExports = function() { + clearExportCount++; + }; + + var afterMount = function(component) { + // Initial state + assert.deepEqual( + { + exportButtonVisible: false, + exports: [], + exportsSelected: [], + collision: false, + }, + component.state + ); + + // Wait for GET exports and GET related learning resources. + waitForAjax(2, function() { + // one export + assert.deepEqual( + { + exports: [learningResourceResponse], + exportButtonVisible: true, + exportsSelected: [learningResourceResponse.id], + collision: false + }, + component.state + ); + + var $node = $(React.findDOMNode(component)); + var button = $node.find("button")[0]; + + // This matches against an empty set of ids which verifies that + // exportsSelected affects what's sent to the server. + TestUtils.replaceMockjax({ + url: '/api/v1/repositories/repo/' + + 'learning_resource_export_tasks/', + type: 'POST', + responseText: { + id: "task2" + }, + data: JSON.stringify({ + ids: [] + }) + }); + + component.setState({ + exportsSelected: [] + }, function() { + // Click the button. There should be a POST to start the task + // followed by an immediate GET to check status. + React.addons.TestUtils.Simulate.click(button); + waitForAjax(2, function() { + // If we reach this point the request data matched successfully. + done(); + }); + }); + }); + }; + + React.addons.TestUtils.renderIntoDocument( + ); + }); + + QUnit.test("Verify that checkboxes alter state", function(assert) { + var done = assert.async(); + + var response1 = learningResourceResponse; + var response2 = $.extend({}, learningResourceResponse, {id: 345}); + var response3 = $.extend({}, learningResourceResponse, {id: 789}); + + TestUtils.replaceMockjax({ + url: '/api/v1/repositories/repo/learning_resource_exports/user/', + type: 'GET', + responseText: { + "count": 3, + "next": null, + "previous": null, + "results": [ + {"id": response1.id}, + {"id": response2.id}, + {"id": response3.id}] + } + }); + TestUtils.initMockjax({ + url: '/api/v1/repositories/repo/learning_resources/?id=123%2C345%2C789', + type: 'GET', + responseText: { + "count": 3, + "next": null, + "previous": null, + "results": [ + response1, + response2, + response3 + ] + } + }); + + var afterMount = function(component) { + var node = React.findDOMNode(component); + // Wait for GET exports and GET related learning resources. + waitForAjax(2, function() { + assert.deepEqual( + component.state.exportsSelected, + [response1.id, response2.id, response3.id] + ); + + // Deselect first and second + $($(node).find("ins")[0]).click(); + $($(node).find("ins")[1]).click(); + + component.forceUpdate(function() { + assert.deepEqual( + component.state.exportsSelected, + [response3.id] + ); + + // Select first one + $($(node).find("ins")[1]).click(); + component.forceUpdate(function() { + assert.deepEqual( + component.state.exportsSelected, + [response3.id, response2.id] + ); + done(); + }); + }); + }); + }; + + React.addons.TestUtils.renderIntoDocument( + + ); + }); }); diff --git a/ui/static/ui/css/mit-lore.css b/ui/static/ui/css/mit-lore.css index 828580cf..6c01afc4 100644 --- a/ui/static/ui/css/mit-lore.css +++ b/ui/static/ui/css/mit-lore.css @@ -219,6 +219,22 @@ a { content: "\00a0"; } +.export-list li { + margin-bottom:24px; +} + +.export-list li .tile-blurb { + display:block; + margin:0 0 0 34px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.export-list label { + font-weight:700; +} + /* results */ .col-results { border-left:1px dashed #E3E6EA; diff --git a/ui/static/ui/js/lr_exports.jsx b/ui/static/ui/js/lr_exports.jsx index 12a82f1f..5bb7e708 100644 --- a/ui/static/ui/js/lr_exports.jsx +++ b/ui/static/ui/js/lr_exports.jsx @@ -1,8 +1,10 @@ define("lr_exports", - ['reactaddons', 'jquery', 'lodash', 'utils'], function (React, $, _, Utils) { + ['reactaddons', 'jquery', 'lodash', 'utils', 'icheck'], + function (React, $, _, Utils) { 'use strict'; var StatusBox = Utils.StatusBox; + var ICheckbox = Utils.ICheckbox; /** * A React component which shows the list of exports @@ -35,9 +37,13 @@ define("lr_exports", return; } if (learningResources.length > 0) { + var exportsSelected = _.map(learningResources, function(resource) { + return resource.id; + }); thiz.setState({ exports: learningResources, - exportButtonVisible: true + exportButtonVisible: true, + exportsSelected: exportsSelected }); } }) @@ -53,9 +59,32 @@ define("lr_exports", }); }); }, + updateChecked: function(exportId, event) { + if (event.target.checked) { + this.setState({ + exportsSelected: _.uniq(this.state.exportsSelected.concat([exportId])) + }); + } else { + this.setState({ + exportsSelected: _.remove(this.state.exportsSelected, function(id) { + return id !== exportId; + }) + }); + } + }, render: function() { + var thiz = this; + var exports = _.map(this.state.exports, function(ex) { - return
  • {ex.title}
  • ; + var checked = _.includes(thiz.state.exportsSelected, ex.id); + return
  • + + + {ex.description} +
  • ; }); var urlLink = null; @@ -75,34 +104,45 @@ define("lr_exports", displayExportButton = "none"; } + var collisionMessage = null; + if (this.state.collision) { + collisionMessage = "WARNING: some static assets had the " + + "same filename and were automatically renamed."; + } + return
    -
      +
        {exports}
      + {collisionMessage} {urlLink} {iframe} - + >Export Selected Items
    ; }, getInitialState: function() { return { exports: [], - exportButtonVisible: false // This will become true when exports load. + exportButtonVisible: false, // This will become true when exports load. + exportsSelected: [], + collision: false }; }, startArchiving: function() { var thiz = this; - var exportIds = _.map(this.state.exports, function(ex) { - return ex.id; - }); - - $.post("/api/v1/repositories/" + this.props.repoSlug + - "/learning_resource_export_tasks/", exportIds).then(function(result) { + $.ajax({ + type: "POST", + url: "/api/v1/repositories/" + this.props.repoSlug + + "/learning_resource_export_tasks/", + data: JSON.stringify({ids: this.state.exportsSelected}), + contentType: 'application/json' + } + ).then(function(result) { if (!thiz.isMounted()) { return; } @@ -145,7 +185,8 @@ define("lr_exports", "/learning_resource_exports/" + thiz.props.loggedInUser + "/" }).then(function() { thiz.setState({ - exports: [] + exports: [], + exportsSelected: [] }); thiz.props.clearExports(); }).fail(function() { @@ -157,7 +198,8 @@ define("lr_exports", }); thiz.setState({ - url: result.url + url: result.url, + collision: result.collision }); } else if (result.status === "processing") { setTimeout(function() { diff --git a/ui/templates/includes/exports_panel.html b/ui/templates/includes/exports_panel.html index 2789d13a..95b33a6a 100644 --- a/ui/templates/includes/exports_panel.html +++ b/ui/templates/includes/exports_panel.html @@ -2,7 +2,11 @@
    -

    Exports

    +

    Export + {% if exports %} + ({{ exports|length }}) + {% endif %} +

    Close