Merge pull request #170 from edx/feature/cdodge/soft-delete-assets
Feature/cdodge/soft delete assets
This commit is contained in:
@@ -15,6 +15,8 @@ Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox.
|
||||
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
|
||||
captions.
|
||||
|
||||
CMS: Allow editors to delete uploaded files/assets
|
||||
|
||||
LMS: Some errors handling Non-ASCII data in XML courses have been fixed.
|
||||
|
||||
LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.contentstore.utils import empty_asset_trashcan
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from .prompt import query_yes_no
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Empty the trashcan. Can pass an optional course_id to limit the damage.'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1 and len(args) != 0:
|
||||
raise CommandError("empty_asset_trashcan requires one or no arguments: |<location>|")
|
||||
|
||||
locs = []
|
||||
|
||||
if len(args) == 1:
|
||||
locs.append(CourseDescriptor.id_to_location(args[0]))
|
||||
else:
|
||||
courses = modulestore('direct').get_courses()
|
||||
for course in courses:
|
||||
locs.append(course.location)
|
||||
|
||||
if query_yes_no("Emptying trashcan. Confirm?", default="no"):
|
||||
empty_asset_trashcan(locs)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.contentstore.utils import restore_asset_from_trashcan
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Restore a deleted asset from the trashcan back to it's original course'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1 and len(args) != 0:
|
||||
raise CommandError("restore_asset_from_trashcan requires one argument: <location>")
|
||||
|
||||
restore_asset_from_trashcan(args[0])
|
||||
|
||||
@@ -28,6 +28,8 @@ from xmodule.templates import update_templates
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.utils import restore_asset_from_trashcan, empty_asset_trashcan
|
||||
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
@@ -35,6 +37,7 @@ from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_TYPES
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from django_comment_common.utils import are_permissions_roles_seeded
|
||||
from xmodule.exceptions import InvalidVersionError
|
||||
@@ -382,6 +385,131 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
course = module_store.get_item(source_location)
|
||||
self.assertFalse(course.hide_progress_tab)
|
||||
|
||||
def test_asset_import(self):
|
||||
'''
|
||||
This test validates that an image asset is imported and a thumbnail was generated for a .gif
|
||||
'''
|
||||
content_store = contentstore()
|
||||
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
self.assertIsNotNone(course)
|
||||
|
||||
# make sure we have some assets in our contentstore
|
||||
all_assets = content_store.get_all_content_for_course(course_location)
|
||||
self.assertGreater(len(all_assets), 0)
|
||||
|
||||
# make sure we have some thumbnails in our contentstore
|
||||
all_thumbnails = content_store.get_all_content_thumbnails_for_course(course_location)
|
||||
self.assertGreater(len(all_thumbnails), 0)
|
||||
|
||||
content = None
|
||||
try:
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
content = content_store.find(location)
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
self.assertIsNotNone(content)
|
||||
self.assertIsNotNone(content.thumbnail_location)
|
||||
|
||||
thumbnail = None
|
||||
try:
|
||||
thumbnail = content_store.find(content.thumbnail_location)
|
||||
except:
|
||||
pass
|
||||
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
def test_asset_delete_and_restore(self):
|
||||
'''
|
||||
This test will exercise the soft delete/restore functionality of the assets
|
||||
'''
|
||||
content_store = contentstore()
|
||||
trash_store = contentstore('trashcan')
|
||||
module_store = modulestore('direct')
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
|
||||
# look up original (and thumbnail) in content store, should be there after import
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
content = content_store.find(location, throw_on_not_found=False)
|
||||
thumbnail_location = content.thumbnail_location
|
||||
self.assertIsNotNone(content)
|
||||
self.assertIsNotNone(thumbnail_location)
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
asset_location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
|
||||
# now try to find it in store, but they should not be there any longer
|
||||
content = content_store.find(asset_location, throw_on_not_found=False)
|
||||
thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNone(content)
|
||||
self.assertIsNone(thumbnail)
|
||||
|
||||
# now try to find it and the thumbnail in trashcan - should be in there
|
||||
content = trash_store.find(asset_location, throw_on_not_found=False)
|
||||
thumbnail = trash_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
# let's restore the asset
|
||||
restore_asset_from_trashcan('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
|
||||
# now try to find it in courseware store, and they should be back after restore
|
||||
content = content_store.find(asset_location, throw_on_not_found=False)
|
||||
thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
def test_empty_trashcan(self):
|
||||
'''
|
||||
This test will exercise the empting of the asset trashcan
|
||||
'''
|
||||
content_store = contentstore()
|
||||
trash_store = contentstore('trashcan')
|
||||
module_store = modulestore('direct')
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
content = content_store.find(location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# make sure there's something in the trashcan
|
||||
all_assets = trash_store.get_all_content_for_course(course_location)
|
||||
self.assertGreater(len(all_assets), 0)
|
||||
|
||||
# make sure we have some thumbnails in our trashcan
|
||||
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
|
||||
self.assertGreater(len(all_thumbnails), 0)
|
||||
|
||||
# empty the trashcan
|
||||
empty_asset_trashcan([course_location])
|
||||
|
||||
# make sure trashcan is empty
|
||||
all_assets = trash_store.get_all_content_for_course(course_location)
|
||||
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
|
||||
self.assertEqual(len(all_assets), 0)
|
||||
self.assertEqual(len(all_thumbnails), 0)
|
||||
|
||||
def test_clone_course(self):
|
||||
|
||||
course_data = {
|
||||
|
||||
@@ -25,6 +25,8 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
from xmodule.modulestore import InvalidLocationError
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from ..utils import get_url_reverse
|
||||
from .access import get_location_and_verify_access
|
||||
@@ -78,10 +80,17 @@ def asset_index(request, org, course, name):
|
||||
'active_tab': 'assets',
|
||||
'context_course': course_module,
|
||||
'assets': asset_display,
|
||||
'upload_asset_callback_url': upload_asset_callback_url
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
'remove_asset_callback_url': reverse('remove_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'name': name
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def upload_asset(request, org, course, coursename):
|
||||
'''
|
||||
cdodge: this method allows for POST uploading of files into the course asset library, which will
|
||||
@@ -145,6 +154,57 @@ def upload_asset(request, org, course, coursename):
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def remove_asset(request, org, course, name):
|
||||
'''
|
||||
This method will perform a 'soft-delete' of an asset, which is basically to copy the asset from
|
||||
the main GridFS collection and into a Trashcan
|
||||
'''
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
location = request.POST['location']
|
||||
|
||||
# make sure the location is valid
|
||||
try:
|
||||
loc = StaticContent.get_location_from_path(location)
|
||||
except InvalidLocationError:
|
||||
# return a 'Bad Request' to browser as we have a malformed Location
|
||||
response = HttpResponse()
|
||||
response.status_code = 400
|
||||
return response
|
||||
|
||||
# also make sure the item to delete actually exists
|
||||
try:
|
||||
content = contentstore().find(loc)
|
||||
except NotFoundError:
|
||||
response = HttpResponse()
|
||||
response.status_code = 404
|
||||
return response
|
||||
|
||||
# ok, save the content into the trashcan
|
||||
contentstore('trashcan').save(content)
|
||||
|
||||
# see if there is a thumbnail as well, if so move that as well
|
||||
if content.thumbnail_location is not None:
|
||||
try:
|
||||
thumbnail_content = contentstore().find(content.thumbnail_location)
|
||||
contentstore('trashcan').save(thumbnail_content)
|
||||
# hard delete thumbnail from origin
|
||||
contentstore().delete(thumbnail_content.get_id())
|
||||
# remove from any caching
|
||||
del_cached_content(thumbnail_content.location)
|
||||
except:
|
||||
pass # OK if this is left dangling
|
||||
|
||||
# delete the original
|
||||
contentstore().delete(content.get_id())
|
||||
# remove from cache
|
||||
del_cached_content(content.location)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
|
||||
@@ -228,7 +228,8 @@ PIPELINE_JS = {
|
||||
) + ['js/hesitate.js', 'js/base.js',
|
||||
'js/models/feedback.js', 'js/views/feedback.js',
|
||||
'js/models/section.js', 'js/views/section.js',
|
||||
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js'],
|
||||
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
|
||||
'js/views/assets.js'],
|
||||
'output_filename': 'js/cms-application.js',
|
||||
'test_order': 0
|
||||
},
|
||||
|
||||
@@ -43,10 +43,15 @@ CONTENTSTORE = {
|
||||
'OPTIONS': {
|
||||
'host': 'localhost',
|
||||
'db': 'xcontent',
|
||||
},
|
||||
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
|
||||
'ADDITIONAL_OPTIONS': {
|
||||
'trashcan': {
|
||||
'bucket': 'trash_fs'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
|
||||
@@ -70,7 +70,13 @@ CONTENTSTORE = {
|
||||
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
|
||||
'OPTIONS': {
|
||||
'host': 'localhost',
|
||||
'db': 'xcontent',
|
||||
'db': 'test_xmodule',
|
||||
},
|
||||
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
|
||||
'ADDITIONAL_OPTIONS': {
|
||||
'trashcan': {
|
||||
'bucket': 'trash_fs'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ $(document).ready(function() {
|
||||
|
||||
$modal.bind('click', hideModal);
|
||||
$modalCover.bind('click', hideModal);
|
||||
$('.uploads .upload-button').bind('click', showUploadModal);
|
||||
$('.upload-modal .close-button').bind('click', hideModal);
|
||||
|
||||
$body.on('click', '.embeddable-xml-input', function() {
|
||||
$(this).select();
|
||||
@@ -145,8 +143,6 @@ $(document).ready(function() {
|
||||
$('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate);
|
||||
$('.edit-section-start-save').bind('click', saveSetSectionScheduleDate);
|
||||
|
||||
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
|
||||
|
||||
$body.on('click', '.section-published-date .edit-button', editSectionPublishDate);
|
||||
$body.on('click', '.section-published-date .schedule-button', editSectionPublishDate);
|
||||
$body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate);
|
||||
@@ -398,73 +394,6 @@ function _deleteItem($el) {
|
||||
});
|
||||
}
|
||||
|
||||
function showUploadModal(e) {
|
||||
e.preventDefault();
|
||||
$modal = $('.upload-modal').show();
|
||||
$('.file-input').bind('change', startUpload);
|
||||
$modalCover.show();
|
||||
}
|
||||
|
||||
function showFileSelectionMenu(e) {
|
||||
e.preventDefault();
|
||||
$('.file-input').click();
|
||||
}
|
||||
|
||||
function startUpload(e) {
|
||||
var files = $('.file-input').get(0).files;
|
||||
if (files.length === 0)
|
||||
return;
|
||||
|
||||
$('.upload-modal h1').html(gettext('Uploading…'));
|
||||
$('.upload-modal .file-name').html(files[0].name);
|
||||
$('.upload-modal .file-chooser').ajaxSubmit({
|
||||
beforeSend: resetUploadBar,
|
||||
uploadProgress: showUploadFeedback,
|
||||
complete: displayFinishedUpload
|
||||
});
|
||||
$('.upload-modal .choose-file-button').hide();
|
||||
$('.upload-modal .progress-bar').removeClass('loaded').show();
|
||||
}
|
||||
|
||||
function resetUploadBar() {
|
||||
var percentVal = '0%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
}
|
||||
|
||||
function showUploadFeedback(event, position, total, percentComplete) {
|
||||
var percentVal = percentComplete + '%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
}
|
||||
|
||||
function displayFinishedUpload(xhr) {
|
||||
if (xhr.status = 200) {
|
||||
markAsLoaded();
|
||||
}
|
||||
|
||||
var resp = JSON.parse(xhr.responseText);
|
||||
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
|
||||
$('.upload-modal .embeddable').show();
|
||||
$('.upload-modal .file-name').hide();
|
||||
$('.upload-modal .progress-fill').html(resp.msg);
|
||||
$('.upload-modal .choose-file-button').html(gettext('Load Another File')).show();
|
||||
$('.upload-modal .progress-fill').width('100%');
|
||||
|
||||
// see if this id already exists, if so, then user must have updated an existing piece of content
|
||||
$("tr[data-id='" + resp.url + "']").remove();
|
||||
|
||||
var template = $('#new-asset-element').html();
|
||||
var html = Mustache.to_html(template, resp);
|
||||
$('table > tbody').prepend(html);
|
||||
|
||||
analytics.track('Uploaded a File', {
|
||||
'course': course_location_analytics,
|
||||
'asset_url': resp.url
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function markAsLoaded() {
|
||||
$('.upload-modal .copy-button').css('display', 'inline-block');
|
||||
$('.upload-modal .progress-bar').addClass('loaded');
|
||||
|
||||
@@ -42,6 +42,12 @@ CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({
|
||||
})
|
||||
});
|
||||
|
||||
CMS.Models.ConfirmAssetDeleteMessage = CMS.Models.SystemFeedback.extend({
|
||||
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
|
||||
"intent": "warning"
|
||||
})
|
||||
});
|
||||
|
||||
CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({
|
||||
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
|
||||
"intent": "confirmation"
|
||||
|
||||
128
cms/static/js/views/assets.js
Normal file
128
cms/static/js/views/assets.js
Normal file
@@ -0,0 +1,128 @@
|
||||
$(document).ready(function() {
|
||||
$('.uploads .upload-button').bind('click', showUploadModal);
|
||||
$('.upload-modal .close-button').bind('click', hideModal);
|
||||
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
|
||||
$('.remove-asset-button').bind('click', removeAsset);
|
||||
});
|
||||
|
||||
function removeAsset(e){
|
||||
e.preventDefault();
|
||||
|
||||
var that = this;
|
||||
var msg = new CMS.Models.ConfirmAssetDeleteMessage({
|
||||
title: gettext("Delete File Confirmation"),
|
||||
message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext("OK"),
|
||||
click: function(view) {
|
||||
// call the back-end to actually remove the asset
|
||||
$.post(view.model.get('remove_asset_url'),
|
||||
{ 'location': view.model.get('asset_location') },
|
||||
function() {
|
||||
// show the post-commit confirmation
|
||||
$(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false');
|
||||
view.model.get('row_to_remove').remove();
|
||||
analytics.track('Deleted Asset', {
|
||||
'course': course_location_analytics,
|
||||
'id': view.model.get('asset_location')
|
||||
});
|
||||
}
|
||||
);
|
||||
view.hide();
|
||||
}
|
||||
},
|
||||
secondary: [{
|
||||
text: gettext("Cancel"),
|
||||
click: function(view) {
|
||||
view.hide();
|
||||
}
|
||||
}]
|
||||
},
|
||||
remove_asset_url: $('.asset-library').data('remove-asset-callback-url'),
|
||||
asset_location: $(this).closest('tr').data('id'),
|
||||
row_to_remove: $(this).closest('tr')
|
||||
});
|
||||
|
||||
// workaround for now. We can't spawn multiple instances of the Prompt View
|
||||
// so for now, a bit of hackery to just make sure we have a single instance
|
||||
// note: confirm_delete_prompt is in asset_index.html
|
||||
if (confirm_delete_prompt === null)
|
||||
confirm_delete_prompt = new CMS.Views.Prompt({model: msg});
|
||||
else
|
||||
{
|
||||
confirm_delete_prompt.model = msg;
|
||||
confirm_delete_prompt.show();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function showUploadModal(e) {
|
||||
e.preventDefault();
|
||||
$modal = $('.upload-modal').show();
|
||||
$('.file-input').bind('change', startUpload);
|
||||
$modalCover.show();
|
||||
}
|
||||
|
||||
function showFileSelectionMenu(e) {
|
||||
e.preventDefault();
|
||||
$('.file-input').click();
|
||||
}
|
||||
|
||||
function startUpload(e) {
|
||||
var files = $('.file-input').get(0).files;
|
||||
if (files.length === 0)
|
||||
return;
|
||||
|
||||
$('.upload-modal h1').html(gettext('Uploading…'));
|
||||
$('.upload-modal .file-name').html(files[0].name);
|
||||
$('.upload-modal .file-chooser').ajaxSubmit({
|
||||
beforeSend: resetUploadBar,
|
||||
uploadProgress: showUploadFeedback,
|
||||
complete: displayFinishedUpload
|
||||
});
|
||||
$('.upload-modal .choose-file-button').hide();
|
||||
$('.upload-modal .progress-bar').removeClass('loaded').show();
|
||||
}
|
||||
|
||||
function resetUploadBar() {
|
||||
var percentVal = '0%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
}
|
||||
|
||||
function showUploadFeedback(event, position, total, percentComplete) {
|
||||
var percentVal = percentComplete + '%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
}
|
||||
|
||||
function displayFinishedUpload(xhr) {
|
||||
if (xhr.status == 200) {
|
||||
markAsLoaded();
|
||||
}
|
||||
|
||||
var resp = JSON.parse(xhr.responseText);
|
||||
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
|
||||
$('.upload-modal .embeddable').show();
|
||||
$('.upload-modal .file-name').hide();
|
||||
$('.upload-modal .progress-fill').html(resp.msg);
|
||||
$('.upload-modal .choose-file-button').html(gettext('Load Another File')).show();
|
||||
$('.upload-modal .progress-fill').width('100%');
|
||||
|
||||
// see if this id already exists, if so, then user must have updated an existing piece of content
|
||||
$("tr[data-id='" + resp.url + "']").remove();
|
||||
|
||||
var template = $('#new-asset-element').html();
|
||||
var html = Mustache.to_html(template, resp);
|
||||
$('table > tbody').prepend(html);
|
||||
|
||||
// re-bind the listeners to delete it
|
||||
$('.remove-asset-button').bind('click', removeAsset);
|
||||
|
||||
analytics.track('Uploaded a File', {
|
||||
'course': course_location_analytics,
|
||||
'asset_url': resp.url
|
||||
});
|
||||
}
|
||||
@@ -76,6 +76,10 @@ body.course.uploads {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.delete-col {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.embeddable-xml-input {
|
||||
@include box-shadow(none);
|
||||
width: 100%;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<%inherit file="base.html" />
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%block name="bodyclass">is-signedin course uploads</%block>
|
||||
<%block name="title">Files & Uploads</%block>
|
||||
|
||||
@@ -7,6 +8,12 @@
|
||||
|
||||
<%block name="jsextra">
|
||||
<script src="${static.url('js/vendor/mustache.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/assets.js')}"></script>
|
||||
|
||||
<script type='text/javascript'>
|
||||
// we just want a singleton
|
||||
confirm_delete_prompt = null;
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
@@ -30,6 +37,9 @@
|
||||
<td class="embed-col">
|
||||
<input type="text" class="embeddable-xml-input" value='{{url}}' readonly>
|
||||
</td>
|
||||
<td class="delete-col">
|
||||
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</script>
|
||||
|
||||
@@ -56,7 +66,7 @@
|
||||
<div class="page-actions">
|
||||
<input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/>
|
||||
</div>
|
||||
<article class="asset-library">
|
||||
<article class="asset-library" data-remove-asset-callback-url='${remove_asset_callback_url}'>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -64,6 +74,7 @@
|
||||
<th class="name-col">Name</th>
|
||||
<th class="date-col">Date Added</th>
|
||||
<th class="embed-col">URL</th>
|
||||
<th class="delete-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="asset_table_body">
|
||||
@@ -86,6 +97,9 @@
|
||||
<td class="embed-col">
|
||||
<input type="text" class="embeddable-xml-input" value="${asset['url']}" readonly>
|
||||
</td>
|
||||
<td class="delete-col">
|
||||
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
|
||||
</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</tbody>
|
||||
@@ -129,3 +143,21 @@
|
||||
|
||||
|
||||
</%block>
|
||||
|
||||
<%block name="view_alerts">
|
||||
<!-- alert: save confirmed with close -->
|
||||
<div class="wrapper wrapper-alert wrapper-alert-confirmation" role="status">
|
||||
<div class="alert confirmation">
|
||||
<i class="icon-ok"></i>
|
||||
|
||||
<div class="copy">
|
||||
<h2 class="title title-3">${_('Your file has been deleted.')}</h2>
|
||||
</div>
|
||||
|
||||
<a href="" rel="view" class="action action-alert-close">
|
||||
<i class="icon-remove-sign"></i>
|
||||
<span class="label">${_('close alert')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</%block>
|
||||
|
||||
@@ -35,6 +35,8 @@ urlpatterns = ('', # nopep8
|
||||
'contentstore.views.preview_dispatch', name='preview_dispatch'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$',
|
||||
'contentstore.views.upload_asset', name='upload_asset'),
|
||||
|
||||
|
||||
url(r'^manage_users/(?P<location>.*?)$', 'contentstore.views.manage_users', name='manage_users'),
|
||||
url(r'^add_user/(?P<location>.*?)$',
|
||||
'contentstore.views.add_user', name='add_user'),
|
||||
@@ -71,8 +73,11 @@ urlpatterns = ('', # nopep8
|
||||
'contentstore.views.edit_static', name='edit_static'),
|
||||
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
|
||||
'contentstore.views.edit_tabs', name='edit_tabs'),
|
||||
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$',
|
||||
'contentstore.views.asset_index', name='asset_index'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)/remove$',
|
||||
'contentstore.views.assets.remove_asset', name='remove_asset'),
|
||||
|
||||
# this is a generic method to return the data/metadata associated with a xmodule
|
||||
url(r'^module_info/(?P<module_location>.*)$',
|
||||
|
||||
@@ -3,7 +3,7 @@ from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
_CONTENTSTORE = None
|
||||
_CONTENTSTORE = {}
|
||||
|
||||
|
||||
def load_function(path):
|
||||
@@ -17,13 +17,16 @@ def load_function(path):
|
||||
return getattr(import_module(module_path), name)
|
||||
|
||||
|
||||
def contentstore():
|
||||
def contentstore(name='default'):
|
||||
global _CONTENTSTORE
|
||||
|
||||
if _CONTENTSTORE is None:
|
||||
if name not in _CONTENTSTORE:
|
||||
class_ = load_function(settings.CONTENTSTORE['ENGINE'])
|
||||
options = {}
|
||||
options.update(settings.CONTENTSTORE['OPTIONS'])
|
||||
_CONTENTSTORE = class_(**options)
|
||||
if 'ADDITIONAL_OPTIONS' in settings.CONTENTSTORE:
|
||||
if name in settings.CONTENTSTORE['ADDITIONAL_OPTIONS']:
|
||||
options.update(settings.CONTENTSTORE['ADDITIONAL_OPTIONS'][name])
|
||||
_CONTENTSTORE[name] = class_(**options)
|
||||
|
||||
return _CONTENTSTORE
|
||||
return _CONTENTSTORE[name]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from bson.son import SON
|
||||
from pymongo import Connection
|
||||
import gridfs
|
||||
from gridfs.errors import NoFile
|
||||
@@ -15,15 +14,16 @@ import os
|
||||
|
||||
|
||||
class MongoContentStore(ContentStore):
|
||||
def __init__(self, host, db, port=27017, user=None, password=None, **kwargs):
|
||||
def __init__(self, host, db, port=27017, user=None, password=None, bucket='fs', **kwargs):
|
||||
logging.debug('Using MongoDB for static content serving at host={0} db={1}'.format(host, db))
|
||||
_db = Connection(host=host, port=port, **kwargs)[db]
|
||||
|
||||
if user is not None and password is not None:
|
||||
_db.authenticate(user, password)
|
||||
|
||||
self.fs = gridfs.GridFS(_db)
|
||||
self.fs_files = _db["fs.files"] # the underlying collection GridFS uses
|
||||
self.fs = gridfs.GridFS(_db, bucket)
|
||||
|
||||
self.fs_files = _db[bucket + ".files"] # the underlying collection GridFS uses
|
||||
|
||||
def save(self, content):
|
||||
id = content.get_id()
|
||||
@@ -43,7 +43,7 @@ class MongoContentStore(ContentStore):
|
||||
if self.fs.exists({"_id": id}):
|
||||
self.fs.delete(id)
|
||||
|
||||
def find(self, location):
|
||||
def find(self, location, throw_on_not_found=True):
|
||||
id = StaticContent.get_id_from_location(location)
|
||||
try:
|
||||
with self.fs.get(id) as fp:
|
||||
@@ -52,7 +52,10 @@ class MongoContentStore(ContentStore):
|
||||
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:
|
||||
raise NotFoundError()
|
||||
if throw_on_not_found:
|
||||
raise NotFoundError()
|
||||
else:
|
||||
return None
|
||||
|
||||
def export(self, location, output_directory):
|
||||
content = self.find(location)
|
||||
|
||||
49
common/lib/xmodule/xmodule/contentstore/utils.py
Normal file
49
common/lib/xmodule/xmodule/contentstore/utils.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from .django import contentstore
|
||||
|
||||
|
||||
def empty_asset_trashcan(course_locs):
|
||||
'''
|
||||
This method will hard delete all assets (optionally within a course_id) from the trashcan
|
||||
'''
|
||||
store = contentstore('trashcan')
|
||||
|
||||
for course_loc in course_locs:
|
||||
# first delete all of the thumbnails
|
||||
thumbs = store.get_all_content_thumbnails_for_course(course_loc)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
id = StaticContent.get_id_from_location(thumb_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
store.delete(id)
|
||||
|
||||
# then delete all of the assets
|
||||
assets = store.get_all_content_for_course(course_loc)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
id = StaticContent.get_id_from_location(asset_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
store.delete(id)
|
||||
|
||||
|
||||
def restore_asset_from_trashcan(location):
|
||||
'''
|
||||
This method will restore an asset which got soft deleted and put back in the original course
|
||||
'''
|
||||
trash = contentstore('trashcan')
|
||||
store = contentstore()
|
||||
|
||||
loc = StaticContent.get_location_from_path(location)
|
||||
content = trash.find(loc)
|
||||
|
||||
# ok, save the content into the courseware
|
||||
store.save(content)
|
||||
|
||||
# see if there is a thumbnail as well, if so move that as well
|
||||
if content.thumbnail_location is not None:
|
||||
try:
|
||||
thumbnail_content = trash.find(content.thumbnail_location)
|
||||
store.save(thumbnail_content)
|
||||
except:
|
||||
pass # OK if this is left dangling
|
||||
Reference in New Issue
Block a user