From 3fa6e77dff6a5f4c33b6fc28d0555f20c7f846d2 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 13 Jun 2013 11:14:56 -0400 Subject: [PATCH 01/15] add new collection configuration to support a 'trashcan' for assets. Update class factory to allow callers to specify if they want the default set of assets or the trashcan --- cms/envs/dev.py | 7 ++++++- common/lib/xmodule/xmodule/contentstore/django.py | 13 ++++++++----- common/lib/xmodule/xmodule/contentstore/mongo.py | 8 ++++---- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index cbe47a1fe1..988856dbf4 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -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', diff --git a/common/lib/xmodule/xmodule/contentstore/django.py b/common/lib/xmodule/xmodule/contentstore/django.py index 83a2508d96..f163348cc8 100644 --- a/common/lib/xmodule/xmodule/contentstore/django.py +++ b/common/lib/xmodule/xmodule/contentstore/django.py @@ -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] diff --git a/common/lib/xmodule/xmodule/contentstore/mongo.py b/common/lib/xmodule/xmodule/contentstore/mongo.py index 58fadb7957..7d96e132ee 100644 --- a/common/lib/xmodule/xmodule/contentstore/mongo.py +++ b/common/lib/xmodule/xmodule/contentstore/mongo.py @@ -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() From 9b749c4201fc898e17719f39e3885b201b563a1d Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 13 Jun 2013 11:59:19 -0400 Subject: [PATCH 02/15] add ajax callback entry point to remove the asset --- cms/djangoapps/contentstore/views/assets.py | 51 +++++++++++++++++++++ cms/urls.py | 5 ++ 2 files changed, 56 insertions(+) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 229788f24d..f1f51b3ca9 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -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 @@ -82,6 +84,8 @@ def asset_index(request, org, course, 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 +149,53 @@ def upload_asset(request, org, course, coursename): return response +@ensure_csrf_cookie +@login_required +def remove_asset(request, org, course, name, location): + ''' + 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) + + # make sure the location is valid + try: + loc = StaticContent.get_location_from_path(request.path) + 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) + + @ensure_csrf_cookie @login_required def import_course(request, org, course, name): diff --git a/cms/urls.py b/cms/urls.py index e7444de4e9..ebd5e33323 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -35,6 +35,8 @@ urlpatterns = ('', # nopep8 'contentstore.views.preview_dispatch', name='preview_dispatch'), url(r'^(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)/upload_asset$', 'contentstore.views.upload_asset', name='upload_asset'), + + url(r'^manage_users/(?P.*?)$', 'contentstore.views.manage_users', name='manage_users'), url(r'^add_user/(?P.*?)$', '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[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'), + url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', 'contentstore.views.asset_index', name='asset_index'), + url(r'^(?P[^/]+)/(?P[^/]+)/assets/remove/(?P.*?)$', + 'contentstore.views.remove_asset', name='remove_asset'), # this is a generic method to return the data/metadata associated with a xmodule url(r'^module_info/(?P.*)$', From 85b904f176c11cd4af52afaf1bdd35509ce49193 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 13 Jun 2013 16:01:06 -0400 Subject: [PATCH 03/15] fix sizing of the delete column --- .../commands/empty_asset_trashcan.py | 28 +++++++++++ .../commands/restore_asset_from_trashcan.py | 17 +++++++ cms/djangoapps/contentstore/views/assets.py | 16 ++++-- cms/static/js/base.js | 24 +++++++++ cms/static/sass/views/_assets.scss | 4 ++ cms/templates/asset_index.html | 28 ++++++++++- cms/urls.py | 4 +- .../lib/xmodule/xmodule/contentstore/utils.py | 49 +++++++++++++++++++ 8 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py create mode 100644 cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py create mode 100644 common/lib/xmodule/xmodule/contentstore/utils.py diff --git a/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py new file mode 100644 index 0000000000..c10700c7af --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py @@ -0,0 +1,28 @@ +### +### Script for cloning a course +### +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: ||") + + 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) diff --git a/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py new file mode 100644 index 0000000000..0a4be40efc --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py @@ -0,0 +1,17 @@ +### +### Script for cloning a course +### +from django.core.management.base import BaseCommand, CommandError +from xmodule.contentstore.utils import restore_asset_from_trashcan +from xmodule.modulestore import Location + + +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: ") + + restore_asset_from_trashcan(args[0]) + diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index f1f51b3ca9..62dee1ba21 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -80,7 +80,12 @@ 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 + }) }) @@ -151,16 +156,19 @@ def upload_asset(request, org, course, coursename): @ensure_csrf_cookie @login_required -def remove_asset(request, org, course, name, location): +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'] + logging.debug('location = {0}'.format(location)) + # make sure the location is valid try: - loc = StaticContent.get_location_from_path(request.path) + loc = StaticContent.get_location_from_path(location) except InvalidLocationError: # return a 'Bad Request' to browser as we have a malformed Location response = HttpResponse() @@ -195,6 +203,8 @@ def remove_asset(request, org, course, name, location): # remove from cache del_cached_content(content.location) + return HttpResponse() + @ensure_csrf_cookie @login_required diff --git a/cms/static/js/base.js b/cms/static/js/base.js index c626fa1b3f..9cb70592cb 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -146,6 +146,7 @@ $(document).ready(function() { $('.edit-section-start-save').bind('click', saveSetSectionScheduleDate); $('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu); + $('.remove-asset-button').bind('click', removeAsset); $body.on('click', '.section-published-date .edit-button', editSectionPublishDate); $body.on('click', '.section-published-date .schedule-button', editSectionPublishDate); @@ -398,6 +399,29 @@ function _deleteItem($el) { }); } +function removeAsset(e) { + e.preventDefault(); + + // replace with new notification moodal + if (!confirm('Are you sure you wish to delete this item. It cannot be reversed!')) return; + + var remove_asset_url = $('.asset-library').data('remove-asset-callback-url'); + var location = $(this).closest('tr').data('id'); + var that = this; + $.post(remove_asset_url, + { 'location': location }, + function() { + // show the alert + $(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false'); + $(that).closest('tr').remove(); + analytics.track('Deleted Asset', { + 'course': course_location_analytics, + 'id': location + }); + } + ); +} + function showUploadModal(e) { e.preventDefault(); $modal = $('.upload-modal').show(); diff --git a/cms/static/sass/views/_assets.scss b/cms/static/sass/views/_assets.scss index d01dd988ef..d4cff42ee9 100644 --- a/cms/static/sass/views/_assets.scss +++ b/cms/static/sass/views/_assets.scss @@ -76,6 +76,10 @@ body.course.uploads { width: 250px; } + .delete-col { + width: 20px; + } + .embeddable-xml-input { @include box-shadow(none); width: 100%; diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index f03a9012f8..0de38510f5 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -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 name="title">Files & Uploads @@ -30,6 +31,9 @@ + + + @@ -56,7 +60,7 @@
-
+
@@ -64,6 +68,7 @@ + @@ -86,6 +91,9 @@ + % endfor @@ -129,3 +137,21 @@ + +<%block name="view_alerts"> + +
+
+ + +
+

${_('Your file has been deleted.')}

+
+ + + + close alert + +
+
+ diff --git a/cms/urls.py b/cms/urls.py index ebd5e33323..a9a7f0a68a 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -76,8 +76,8 @@ urlpatterns = ('', # nopep8 url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', 'contentstore.views.asset_index', name='asset_index'), - url(r'^(?P[^/]+)/(?P[^/]+)/assets/remove/(?P.*?)$', - 'contentstore.views.remove_asset', name='remove_asset'), + url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)/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.*)$', diff --git a/common/lib/xmodule/xmodule/contentstore/utils.py b/common/lib/xmodule/xmodule/contentstore/utils.py new file mode 100644 index 0000000000..fadc06c84e --- /dev/null +++ b/common/lib/xmodule/xmodule/contentstore/utils.py @@ -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=None): + ''' + 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 From 443d6a382f0e43f18c1b3f6d61e069c8c3542e79 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 13 Jun 2013 16:06:09 -0400 Subject: [PATCH 04/15] add CHANGELOG entry for work --- CHANGELOG.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 03a3b8c809..84e1bb474b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 From 274d8997a21baaf9ed7310f2693eb9ab961ccc3b Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 13 Jun 2013 16:15:48 -0400 Subject: [PATCH 05/15] update warning message --- cms/static/js/base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 9cb70592cb..aa34f1b7c2 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -403,7 +403,7 @@ function removeAsset(e) { e.preventDefault(); // replace with new notification moodal - if (!confirm('Are you sure you wish to delete this item. It cannot be reversed!')) return; + if (!confirm('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)')) return; var remove_asset_url = $('.asset-library').data('remove-asset-callback-url'); var location = $(this).closest('tr').data('id'); From 70ee4b80602e0508e91c25c1d034698ef0adacd4 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 14 Jun 2013 10:47:32 -0400 Subject: [PATCH 06/15] add unit tests for asset delete/restore --- .../contentstore/tests/test_contentstore.py | 108 ++++++++++++++++++ cms/djangoapps/contentstore/views/assets.py | 1 - cms/envs/test.py | 8 +- .../xmodule/modulestore/xml_importer.py | 2 +- 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 03449fc22f..4e96b6cb5f 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -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 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,111 @@ 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) + + 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) + + # 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 = None + thumbnail = None + try: + content = content_store.find(asset_location) + thumbnail = trash_store.find(content.thumbnail_location) + except NotFoundError: + pass + self.assertIsNone(content) + self.assertIsNone(thumbnail) + + # now try to find it and the thumbnail in trashcan + content = None + thumbnail = None + try: + content = trash_store.find(asset_location) + thumbnail = trash_store.find(content.thumbnail_location) + except NotFoundError: + pass + + # should be in trashcan + 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 = None + thumbnail = None + try: + content = content_store.find(asset_location) + thumbnail = trash_store.find(content.thumbnail_location) + except NotFoundError: + pass + self.assertIsNotNone(content) + self.assertIsNotNone(thumbnail) + def test_clone_course(self): course_data = { diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 62dee1ba21..400013b59b 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -164,7 +164,6 @@ def remove_asset(request, org, course, name): get_location_and_verify_access(request, org, course, name) location = request.POST['location'] - logging.debug('location = {0}'.format(location)) # make sure the location is valid try: diff --git a/cms/envs/test.py b/cms/envs/test.py index 1569d0a42d..954a553e10 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -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' + } } } diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 71c6983644..8d6aa86918 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -30,7 +30,7 @@ def import_static_content(modules, course_loc, course_data_path, static_content_ try: content_path = os.path.join(dirname, filename) - if verbose: + if True: log.debug('importing static content {0}...'.format(content_path)) fullname_with_subpath = content_path.replace(static_dir, '') # strip away leading path from the name From cd0087ca531c1db822515c5dc6464e5b893f0869 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 14 Jun 2013 12:39:34 -0400 Subject: [PATCH 07/15] use the new notification tools for the confirmation dialog --- cms/static/js/base.js | 67 ++++++++++++++++++++++++-------- cms/static/js/models/feedback.js | 6 +++ cms/templates/asset_index.html | 5 +++ 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index aa34f1b7c2..ed0bdff8bd 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -402,24 +402,54 @@ function _deleteItem($el) { function removeAsset(e) { e.preventDefault(); - // replace with new notification moodal - if (!confirm('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)')) return; - - var remove_asset_url = $('.asset-library').data('remove-asset-callback-url'); - var location = $(this).closest('tr').data('id'); var that = this; - $.post(remove_asset_url, - { 'location': location }, - function() { - // show the alert - $(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false'); - $(that).closest('tr').remove(); - analytics.track('Deleted Asset', { - 'course': course_location_analytics, - 'id': location - }); - } - ); + 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) { @@ -482,6 +512,9 @@ function displayFinishedUpload(xhr) { 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 diff --git a/cms/static/js/models/feedback.js b/cms/static/js/models/feedback.js index 1f1ee57000..d57cffa779 100644 --- a/cms/static/js/models/feedback.js +++ b/cms/static/js/models/feedback.js @@ -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" diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 0de38510f5..0fcfee0b83 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -8,6 +8,11 @@ <%block name="jsextra"> + + <%block name="content"> From b443629ee8c8dd37a14643cd52ed66c636b48583 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 14 Jun 2013 15:26:37 -0400 Subject: [PATCH 08/15] remove invalid comments --- .../contentstore/management/commands/empty_asset_trashcan.py | 3 --- .../management/commands/restore_asset_from_trashcan.py | 4 ---- 2 files changed, 7 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py index c10700c7af..9af3277a2b 100644 --- a/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py +++ b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py @@ -1,6 +1,3 @@ -### -### Script for cloning a course -### from django.core.management.base import BaseCommand, CommandError from xmodule.course_module import CourseDescriptor from xmodule.contentstore.utils import empty_asset_trashcan diff --git a/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py index 0a4be40efc..6770bfaf44 100644 --- a/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py +++ b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py @@ -1,9 +1,5 @@ -### -### Script for cloning a course -### from django.core.management.base import BaseCommand, CommandError from xmodule.contentstore.utils import restore_asset_from_trashcan -from xmodule.modulestore import Location class Command(BaseCommand): From ab94b8618c49a16e29d39683eee433acf26db4e9 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 14 Jun 2013 15:32:02 -0400 Subject: [PATCH 09/15] forgot to internationalize one string --- cms/templates/asset_index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 0fcfee0b83..0006d29d38 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -155,7 +155,7 @@ - close alert + ${_('close alert')} From 7f1768f8f8891f8098a56ad908c0693520232006 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 14 Jun 2013 15:37:14 -0400 Subject: [PATCH 10/15] put back the verbose switch in xml_importer.py --- common/lib/xmodule/xmodule/modulestore/xml_importer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 8d6aa86918..71c6983644 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -30,7 +30,7 @@ def import_static_content(modules, course_loc, course_data_path, static_content_ try: content_path = os.path.join(dirname, filename) - if True: + if verbose: log.debug('importing static content {0}...'.format(content_path)) fullname_with_subpath = content_path.replace(static_dir, '') # strip away leading path from the name From 7e46447cd5a80b692f405747035118923f5e7dbe Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 14 Jun 2013 16:08:19 -0400 Subject: [PATCH 11/15] there is no default for empty_asset_trashcan, caller must provide a list --- common/lib/xmodule/xmodule/contentstore/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/contentstore/utils.py b/common/lib/xmodule/xmodule/contentstore/utils.py index fadc06c84e..74c4242bd9 100644 --- a/common/lib/xmodule/xmodule/contentstore/utils.py +++ b/common/lib/xmodule/xmodule/contentstore/utils.py @@ -3,7 +3,7 @@ from xmodule.contentstore.content import StaticContent from .django import contentstore -def empty_asset_trashcan(course_locs=None): +def empty_asset_trashcan(course_locs): ''' This method will hard delete all assets (optionally within a course_id) from the trashcan ''' From dc94ea10415557214739ce7f38408f1d3052f456 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 14 Jun 2013 16:19:17 -0400 Subject: [PATCH 12/15] add unit test for emptying the trashcan --- .../contentstore/tests/test_contentstore.py | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 4e96b6cb5f..5b829781c4 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -29,7 +29,7 @@ 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 +from xmodule.contentstore.utils import restore_asset_from_trashcan, empty_asset_trashcan from xmodule.capa_module import CapaDescriptor from xmodule.course_module import CourseDescriptor @@ -490,6 +490,50 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 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') + + 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) + + # 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 = { From 7cebb873df947a96a02cb38f7cc465f8c5d040b5 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 14 Jun 2013 16:35:21 -0400 Subject: [PATCH 13/15] allow for an optional throw_on_not_found on contentstore().find() so we can clean up caller logic in the tests --- .../contentstore/tests/test_contentstore.py | 52 +++++-------------- .../lib/xmodule/xmodule/contentstore/mongo.py | 7 ++- 2 files changed, 19 insertions(+), 40 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 5b829781c4..a503f22c26 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -435,14 +435,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) - content = None - try: - location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') - content = content_store.find(location) - except NotFoundError: - pass - + # 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 @@ -453,26 +451,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 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 = None - thumbnail = None - try: - content = content_store.find(asset_location) - thumbnail = trash_store.find(content.thumbnail_location) - except NotFoundError: - pass + 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 - content = None - thumbnail = None - try: - content = trash_store.find(asset_location) - thumbnail = trash_store.find(content.thumbnail_location) - except NotFoundError: - pass - - # should be in trashcan + # 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) @@ -480,13 +466,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 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 = None - thumbnail = None - try: - content = content_store.find(asset_location) - thumbnail = trash_store.find(content.thumbnail_location) - except NotFoundError: - pass + 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) @@ -502,13 +483,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - content = None - try: - location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') - content = content_store.find(location) - except NotFoundError: - pass - + 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 diff --git a/common/lib/xmodule/xmodule/contentstore/mongo.py b/common/lib/xmodule/xmodule/contentstore/mongo.py index 7d96e132ee..fa0fc95181 100644 --- a/common/lib/xmodule/xmodule/contentstore/mongo.py +++ b/common/lib/xmodule/xmodule/contentstore/mongo.py @@ -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) From 16e476e8e4fae72771bcea117fed41429c206473 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 17 Jun 2013 10:34:09 -0400 Subject: [PATCH 14/15] refactored asset page related JS into it's own page --- cms/static/js/base.js | 128 --------------------------------- cms/static/js/views/assets.js | 128 +++++++++++++++++++++++++++++++++ cms/templates/asset_index.html | 1 + 3 files changed, 129 insertions(+), 128 deletions(-) create mode 100644 cms/static/js/views/assets.js diff --git a/cms/static/js/base.js b/cms/static/js/base.js index ed0bdff8bd..92a16b8417 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -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,9 +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); - $('.remove-asset-button').bind('click', removeAsset); - $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); @@ -399,129 +394,6 @@ function _deleteItem($el) { }); } -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 - }); - -} - function markAsLoaded() { $('.upload-modal .copy-button').css('display', 'inline-block'); $('.upload-modal .progress-bar').addClass('loaded'); diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js new file mode 100644 index 0000000000..9eb521dcb6 --- /dev/null +++ b/cms/static/js/views/assets.js @@ -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 + }); +} \ No newline at end of file diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 0006d29d38..e8dc523ba7 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -8,6 +8,7 @@ <%block name="jsextra"> +
Name Date Added URL
+ +